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 { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery'; import $ from 'jquery';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import { getEmojiScoreWithIntent } from '~/emoji/utils';
import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils'; import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils';
import * as Emoji from '~/emoji'; import * as Emoji from '~/emoji';
import { dispose, fixTitle } from '~/tooltips'; import { dispose, fixTitle } from '~/tooltips';
import createFlash from './flash'; import createFlash from './flash';
import axios from './lib/utils/axios_utils'; 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) { 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 $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements.filter( 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 /* 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_VERSION_KEY = 'gl-emoji-map-version';
export const CACHE_KEY = 'gl-emoji-map'; 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 emojiRegexFactory from 'emoji-regex';
import emojiAliases from 'emojis/aliases.json'; import emojiAliases from 'emojis/aliases.json';
import { setAttributes } from '~/lib/utils/dom_utils'; import { setAttributes } from '~/lib/utils/dom_utils';
import { getEmojiScoreWithIntent } from '~/emoji/utils';
import AccessorUtilities from '../lib/utils/accessor'; import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils'; import axios from '../lib/utils/axios_utils';
import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants';
@ -144,6 +145,11 @@ function getNameMatch(emoji, query) {
return null; 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) { export function searchEmoji(query) {
const lowercaseQuery = query ? `${query}`.toLowerCase() : ''; const lowercaseQuery = query ? `${query}`.toLowerCase() : '';
@ -156,16 +162,14 @@ export function searchEmoji(query) {
getDescriptionMatch(emoji, lowercaseQuery), getDescriptionMatch(emoji, lowercaseQuery),
getAliasMatch(emoji, matchingAliases), getAliasMatch(emoji, matchingAliases),
getNameMatch(emoji, lowercaseQuery), getNameMatch(emoji, lowercaseQuery),
].filter(Boolean); ]
.filter(Boolean)
.map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) }));
return minBy(matches, (x) => x.score); return minBy(matches, (x) => x.score);
}) })
.filter(Boolean); .filter(Boolean)
} .sort(sortEmoji);
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));
} }
export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP); 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); return Emoji.searchEmoji(query);
}, },
sorter(items) { sorter(items) {
return Emoji.sortEmoji(items); return items.sort(Emoji.sortEmoji);
}, },
}; };
// Team Members // Team Members

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue'; import RunnerTags from './runner_tags.vue';
const defaultFields = [ 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: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'version', label: __('Version') }), tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'jobCount', label: __('Jobs') }), 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" #import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getRunners( 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" #import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getGroupRunners( query getGroupRunners(

View File

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

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

View File

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

View File

@ -236,3 +236,17 @@ export const fromSearchToVariables = ({
...paginationVariables, ...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"> <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal">
{{ __('None') }} - {{ __('None') }} -
<gl-button <gl-button
class="gl-ml-2" class="gl-ml-2 gl-reset-color!"
href="#" href="#"
category="tertiary"
variant="link" variant="link"
size="small"
data-testid="unassigned-users" data-testid="unassigned-users"
@click="updateAlertAssignees(currentUser)" @click="updateAlertAssignees(currentUser)"
> >

View File

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

View File

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

View File

@ -563,24 +563,11 @@ $ide-commit-header-height: 48px;
} }
.ide-commit-options { .ide-commit-options {
label { .is-disabled {
font-weight: normal; .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 { .ide-sidebar-link {

View File

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

View File

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

View File

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

View File

@ -41,3 +41,5 @@ class Groups::RunnersController < Groups::ApplicationController
params.require(:runner).permit(Ci::Runner::FORM_EDITABLE) params.require(:runner).permit(Ci::Runner::FORM_EDITABLE)
end end
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. # current_user - user performing the action. Must have the correct permission level for the group.
# params: # params:
# group: Group, required # group: Group, required
# search: String, optional
# state: CustomerRelations::ContactStateEnum, optional
module Crm module Crm
class ContactsFinder class ContactsFinder
include Gitlab::Allowable include Gitlab::Allowable
@ -21,7 +23,10 @@ module Crm
def execute def execute
return CustomerRelations::Contact.none unless root_group 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 end
private private
@ -35,5 +40,25 @@ module Crm
group group
end end
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
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 def preloads
{ {
contacts: [:contacts],
container_repositories_count: [:container_repositories], container_repositories_count: [:container_repositories],
custom_emoji: [:custom_emoji], custom_emoji: [:custom_emoji],
full_path: [:route], full_path: [:route],
organizations: [:organizations],
path: [:route], path: [:route],
dependency_proxy_blob_count: [:dependency_proxy_blobs], dependency_proxy_blob_count: [:dependency_proxy_blobs],
dependency_proxy_blobs: [: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, field :organizations, Types::CustomerRelations::OrganizationType.connection_type,
null: true, 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, field :contacts, Types::CustomerRelations::ContactType.connection_type,
null: true, 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, field :work_item_types, Types::WorkItems::TypeType.connection_type,
resolver: Resolvers::WorkItems::TypesResolver, resolver: Resolvers::WorkItems::TypesResolver,

View File

@ -65,7 +65,9 @@ module Ci
runner_install_help_page: 'https://docs.gitlab.com/runner/install/', runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token, registration_token: Gitlab::CurrentSettings.runners_registration_token,
online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i, 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 end
@ -87,7 +89,9 @@ module Ci
group_full_path: group.full_path, group_full_path: group.full_path,
runner_install_help_page: 'https://docs.gitlab.com/runner/install/', runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i, 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 end

View File

@ -808,6 +808,7 @@ module Ci
def execute_hooks def execute_hooks
return unless project return unless project
return if user&.blocked?
project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks) 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) 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 # 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' 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_save :store_file_in_transaction!, unless: :store_after_commit?
after_commit :store_file_after_commit!, on: [:create, :update], if: :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 validates :file_format, presence: true, unless: :trace?, on: :create
validate :validate_file_format!, unless: :trace?, on: :create validate :validate_file_format!, unless: :trace?, on: :create
@ -362,11 +362,24 @@ module Ci
private private
def store_file_after_commit! def store_file_in_transaction!
return unless previous_changes.key?(:file) store_file_now! if saved_change_to_file?
store_file! file_stored_in_transaction_hooks
update_file_store 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 end
def set_size def set_size

View File

@ -240,7 +240,9 @@ module Ci
next if transition.loopback? next if transition.loopback?
pipeline.run_after_commit do 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? if pipeline.project.jira_subscription_exists?
# Passing the seq-id ensures this is idempotent # Passing the seq-id ensures this is idempotent
@ -297,7 +299,12 @@ module Ci
ref_status = pipeline.ci_ref&.update_status_by!(pipeline) ref_status = pipeline.ci_ref&.update_status_by!(pipeline)
pipeline.run_after_commit do 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
end end

View File

@ -4,9 +4,16 @@ module FileStoreMounter
extend ActiveSupport::Concern extend ActiveSupport::Concern
class_methods do 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) 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 # This hook is a no-op when the file is uploaded after_commit
after_save :update_file_store, if: :saved_change_to_file? after_save :update_file_store, if: :saved_change_to_file?
end end
@ -16,4 +23,9 @@ module FileStoreMounter
# The file.object_store is set during `uploader.store!` and `uploader.migrate!` # The file.object_store is set during `uploader.store!` and `uploader.migrate!`
update_column(:file_store, file.object_store) update_column(:file_store, file.object_store)
end end
def store_file_now!
store_file!
update_file_store
end
end end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class CustomerRelations::Contact < ApplicationRecord class CustomerRelations::Contact < ApplicationRecord
include Gitlab::SQL::Pattern
include Sortable
include StripAttribute include StripAttribute
self.table_name = "customer_relations_contacts" self.table_name = "customer_relations_contacts"
@ -39,6 +41,25 @@ class CustomerRelations::Contact < ApplicationRecord
']' ']'
end 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) def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class CustomerRelations::Organization < ApplicationRecord class CustomerRelations::Organization < ApplicationRecord
include Gitlab::SQL::Pattern
include Sortable
include StripAttribute include StripAttribute
self.table_name = "customer_relations_organizations" self.table_name = "customer_relations_organizations"
@ -21,6 +23,25 @@ class CustomerRelations::Organization < ApplicationRecord
validates :description, length: { maximum: 1024 } validates :description, length: { maximum: 1024 }
validate :validate_root_group 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) def self.find_by_name(group_id, name)
where(group: group_id) where(group: group_id)
.where('LOWER(name) = LOWER(?)', name) .where('LOWER(name) = LOWER(?)', name)

View File

@ -3,6 +3,7 @@
module Projects module Projects
class UpdatePagesService < BaseService class UpdatePagesService < BaseService
InvalidStateError = Class.new(StandardError) InvalidStateError = Class.new(StandardError)
WrongUploadedDeploymentSizeError = Class.new(StandardError)
BLOCK_SIZE = 32.kilobytes BLOCK_SIZE = 32.kilobytes
PUBLIC_DIR = 'public' PUBLIC_DIR = 'public'
@ -39,6 +40,9 @@ module Projects
end end
rescue InvalidStateError => e rescue InvalidStateError => e
error(e.message) error(e.message)
rescue WrongUploadedDeploymentSizeError => e
error("Uploading artifacts to pages storage failed")
raise e
rescue StandardError => e rescue StandardError => e
error(e.message) error(e.message)
raise e raise e
@ -80,6 +84,10 @@ module Projects
ci_build_id: build.id ci_build_id: build.id
) )
if deployment.size != file.size || deployment.file.size != file.size
raise(WrongUploadedDeploymentSizeError)
end
validate_outdated_sha! validate_outdated_sha!
project.update_pages_deployment!(deployment) 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' } } %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 .settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk') %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') = expanded ? _('Collapse') : _('Expand')
- link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe - 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 } %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') - title = s_('TagsPage|Only a project maintainer or owner can delete a protected tag')
- disabled = true - 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 } } = 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 } })
= sprite_icon('remove', css_class: 'gl-icon')

View File

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

View File

@ -23,6 +23,7 @@ class PipelineNotificationWorker # rubocop:disable Scalability/IdempotentWorker
pipeline = Ci::Pipeline.find_by_id(pipeline_id) pipeline = Ci::Pipeline.find_by_id(pipeline_id)
return unless pipeline return unless pipeline
return if pipeline.user&.blocked?
NotificationService.new.pipeline_finished(pipeline, ref_status: ref_status, recipients: recipients) NotificationService.new.pipeline_finished(pipeline, ref_status: ref_status, recipients: recipients)
end 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="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="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="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="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="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. | | <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="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="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="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="grouppackagesettings"></a>`packageSettings` | [`PackageSettings`](#packagesettings) | Package settings for the namespace. |
| <a id="groupparent"></a>`parent` | [`Group`](#group) | Parent group. | | <a id="groupparent"></a>`parent` | [`Group`](#group) | Parent group. |
| <a id="grouppath"></a>`path` | [`String!`](#string) | Path of the namespace. | | <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. | | <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` ##### `Group.containerRepositories`
Container repositories of the group. 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="groupmilestonestimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. |
| <a id="groupmilestonestitle"></a>`title` | [`String`](#string) | Title of the milestone. | | <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` ##### `Group.packages`
Packages of the group. 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_asc"></a>`NAME_ASC` | Ordered by name in ascending order. |
| <a id="containerrepositorytagsortname_desc"></a>`NAME_DESC` | Ordered by name in descending 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` ### `DastProfileCadenceUnit`
Unit for the duration of Dast Profile Cadence. Unit for the duration of Dast Profile Cadence.

View File

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

View File

@ -450,5 +450,26 @@ test-vars-2:
- printenv - printenv
``` ```
You can't reuse a section that already includes a `!reference` tag. Only one level ### Nest `!reference` tags in `script`, `before_script`, and `after_script`
of nesting is supported.
> [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 1. Ensure new sprite sheets generated for 1x and 2x
- `app/assets/images/emoji.png` - `app/assets/images/emoji.png`
- `app/assets/images/emoji@2x.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 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 GitLab Flavored Markdown (GLFM) Autocomplete
1. Ensure you can see the new emojis and their aliases in the award emoji menu 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 Some places in the code refer to both the GitLab and GitHub specifications
simultaneous in the same areas of logic. In these situations, simultaneous in the same areas of logic. In these situations,
_GitHub_ Flavored Markdown may be referred to with variable or constant names like _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). The original CommonMark specification is referred to as _CommonMark_ (no acronym).
@ -434,7 +437,7 @@ subgraph script:
A --> B{Backend Markdown API} A --> B{Backend Markdown API}
end end
subgraph input:<br/>input specification files 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 D[glfm_intro.txt] --> A
E[glfm_canonical_examples.txt] --> A E[glfm_canonical_examples.txt] --> A
end end
@ -572,12 +575,16 @@ updated, as in the case of all GFM files.
##### GitHub Flavored Markdown specification ##### 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). 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. - It is automatically downloaded and updated by `update-specification.rb` script.
- When it is downloaded, the version number is added to the filename. - 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_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) [`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 ```yaml
06_04_inlines_emphasis_and_strong_emphasis_1: 06_04_inlines_emphasis_and_strong_emphasis_1:
canonical: | canonical: |
<p><em>foo bar</em></p> <p><em>foo bar</em></p>
static: | static: |
<p data-sourcepos="1:1-1:9" dir="auto"><strong>foo bar</strong></p> <p data-sourcepos="1:1-1:9" dir="auto"><strong>foo bar</strong></p>
wysiwyg: | wysiwyg: |
<p><strong>foo bar</strong></p> <p><strong>foo bar</strong></p>
``` ```
NOTE: NOTE:

View File

@ -205,7 +205,7 @@ To assign an alert:
![Alert Details View Assignees](img/alert_details_assignees_v13_1.png) ![Alert Details View Assignees](img/alert_details_assignees_v13_1.png)
1. If the right sidebar is not expanded, select 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**. 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. 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**. **Notify only broken pipelines**.
1. Select the branches to send notifications for. 1. Select the branches to send notifications for.
1. Select **Save changes**. 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) In [GitLab 13.9](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53159)
and later, the pipeline webhook returns only the latest jobs. 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: Request header:
```plaintext ```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. 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: Request header:
```plaintext ```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 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: 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. - 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, 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), 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: and the ability to select **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.
If the target branch is ahead of the source branch and a conflict-free rebase is 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. 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 Rebasing may be required before squashing, even though squashing can itself be
considered equivalent to rebasing. 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 ## Related topics
- [Commits history](../commits.md) - [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 module Limit
class RateLimit < Chain::Base class RateLimit < Chain::Base
include Chain::Helpers include Chain::Helpers
include ::Gitlab::Utils::StrongMemoize
def perform! def perform!
# We exclude child-pipelines from the rate limit because they represent # We exclude child-pipelines from the rate limit because they represent
@ -41,7 +42,9 @@ module Gitlab
commit_sha: command.sha, commit_sha: command.sha,
current_user_id: current_user.id, current_user_id: current_user.id,
subscription_plan: project.actual_plan_name, 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 end
@ -50,9 +53,16 @@ module Gitlab
end end
def enforce_throttle? def enforce_throttle?
::Feature.enabled?( strong_memoize(:enforce_throttle) do
:ci_enforce_throttle_pipelines_creation, ::Feature.enabled?(:ci_enforce_throttle_pipelines_creation, project) &&
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 end
end end

View File

@ -17232,7 +17232,7 @@ msgstr ""
msgid "GitLab is open source software to collaborate on code." msgid "GitLab is open source software to collaborate on code."
msgstr "" msgstr ""
msgid "GitLab is undergoing maintenance and is operating in read-only mode." msgid "GitLab is undergoing maintenance"
msgstr "" msgstr ""
msgid "GitLab logo" msgid "GitLab logo"
@ -19088,6 +19088,9 @@ msgstr ""
msgid "IDE|Review" msgid "IDE|Review"
msgstr "" msgstr ""
msgid "IDE|Start a new merge request"
msgstr ""
msgid "IDE|Successful commit" msgid "IDE|Successful commit"
msgstr "" msgstr ""
@ -25758,9 +25761,6 @@ msgstr ""
msgid "No runner executable" msgid "No runner executable"
msgstr "" msgstr ""
msgid "No runners found"
msgstr ""
msgid "No schedules" msgid "No schedules"
msgstr "" 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." 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 "" 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}" 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 "" msgstr ""
@ -32866,6 +32869,9 @@ msgstr ""
msgid "Runners|Download latest binary" msgid "Runners|Download latest binary"
msgstr "" msgstr ""
msgid "Runners|Edit your search and try again"
msgstr ""
msgid "Runners|Enable stale runner cleanup" msgid "Runners|Enable stale runner cleanup"
msgstr "" msgstr ""
@ -32878,6 +32884,9 @@ msgstr ""
msgid "Runners|Executor" msgid "Runners|Executor"
msgstr "" msgstr ""
msgid "Runners|Get started with runners"
msgstr ""
msgid "Runners|Group" msgid "Runners|Group"
msgstr "" msgstr ""
@ -32926,6 +32935,9 @@ msgstr ""
msgid "Runners|New registration token generated!" msgid "Runners|New registration token generated!"
msgstr "" msgstr ""
msgid "Runners|No results found"
msgstr ""
msgid "Runners|No spot. Default choice for Windows Shell executor." msgid "Runners|No spot. Default choice for Windows Shell executor."
msgstr "" msgstr ""
@ -33069,6 +33081,9 @@ msgstr ""
msgid "Runners|Runners" msgid "Runners|Runners"
msgstr "" 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" msgid "Runners|Runs untagged jobs"
msgstr "" msgstr ""
@ -33134,6 +33149,9 @@ msgstr ""
msgid "Runners|This runner is available to all projects and subgroups in a group." msgid "Runners|This runner is available to all projects and subgroups in a group."
msgstr "" 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." msgid "Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation."
msgstr "" msgstr ""
@ -33203,6 +33221,12 @@ msgstr ""
msgid "Runners|stale" msgid "Runners|stale"
msgstr "" msgstr ""
msgid "Runners|upgrade available"
msgstr ""
msgid "Runners|upgrade recommended"
msgstr ""
msgid "Running" msgid "Running"
msgstr "" msgstr ""
@ -36268,9 +36292,6 @@ msgstr ""
msgid "Start a new discussion…" msgid "Start a new discussion…"
msgstr "" msgstr ""
msgid "Start a new merge request"
msgstr ""
msgid "Start a new merge request with these changes" msgid "Start a new merge request with these changes"
msgstr "" 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." 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 "" 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." msgid "This PDF is too large to display. Please download to view."
msgstr "" msgstr ""

View File

@ -56,7 +56,7 @@
"@babel/preset-env": "^7.10.1", "@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.7", "@gitlab/at.js": "1.5.7",
"@gitlab/favicon-overlay": "2.0.0", "@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "2.17.0", "@gitlab/svgs": "2.18.0",
"@gitlab/ui": "40.7.1", "@gitlab/ui": "40.7.1",
"@gitlab/visual-review-tools": "1.7.3", "@gitlab/visual-review-tools": "1.7.3",
"@rails/actioncable": "6.1.4-7", "@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") expect(page).not_to have_content("runner-bar")
end end
it 'shows no runner when description does not match' do context 'when description does not match' do
input_filtered_search_keys('runner-baz') before do
input_filtered_search_keys('runner-baz')
end
expect(page).to have_link('All 0') it_behaves_like 'shows no runners found'
expect(page).to have_link('Instance 0')
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
end end
@ -190,14 +194,6 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content 'runner-never-contacted' expect(page).not_to have_content 'runner-never-contacted'
end 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 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_filter_is_only('Status', 'Online')
input_filtered_search_keys('runner-1') input_filtered_search_keys('runner-1')
@ -225,6 +221,18 @@ RSpec.describe "Admin Runners" do
expect(page).to have_selector '.badge', text: 'never contacted' expect(page).to have_selector '.badge', text: 'never contacted'
end end
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 end
describe 'filter by type' do describe 'filter by type' do
@ -273,21 +281,6 @@ RSpec.describe "Admin Runners" do
end end
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 it 'shows correct runner when type is selected and search term is entered' do
create(:ci_runner, :project, description: 'runner-2-project', projects: [project]) 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-group'
expect(page).not_to have_content 'runner-paused-project' expect(page).not_to have_content 'runner-paused-project'
end 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 end
describe 'filter by tag' do describe 'filter by tag' do
@ -358,15 +369,6 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content 'runner-red' expect(page).not_to have_content 'runner-red'
end 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 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']) 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-blue'
expect(page).not_to have_content 'runner-red' expect(page).not_to have_content 'runner-red'
end 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 end
it 'sorts by last contact date' do it 'sorts by last contact date' do
@ -419,7 +434,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
end end
it_behaves_like "shows no runners" it_behaves_like 'shows no runners registered'
it 'shows tabs with total counts equal to 0' do it 'shows tabs with total counts equal to 0' do
expect(page).to have_link('All 0') expect(page).to have_link('All 0')

View File

@ -33,7 +33,7 @@ RSpec.describe "Group Runners" do
visit group_runners_path(group) visit group_runners_path(group)
end end
it_behaves_like "shows no runners" it_behaves_like 'shows no runners registered'
it 'shows tabs with total counts equal to 0' do it 'shows tabs with total counts equal to 0' do
expect(page).to have_link('All 0') 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)) expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner))
end end
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 end
context "with an online project runner" do context "with an online project runner" do

View File

@ -66,5 +66,83 @@ RSpec.describe Crm::ContactsFinder do
expect(subject).to be_empty expect(subject).to be_empty
end end
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
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', unicodeVersion: '6.0',
description: 'because it contains multiple zero width joiners', 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 = { export const invalidEmoji = {

View File

@ -57,6 +57,18 @@ describe('AwardsHandler', () => {
d: 'white question mark ornament', d: 'white question mark ornament',
u: '6.0', 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') => { const openAndWaitForEmojiMenu = (sel = '.js-add-award') => {
@ -296,6 +308,23 @@ describe('AwardsHandler', () => {
awardsHandler.searchEmojis('👼'); awardsHandler.searchEmojis('👼');
expect($('[data-name=angel]').is(':visible')).toBe(true); 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', () => { describe('emoji menu', () => {

View File

@ -24,6 +24,7 @@ import isEmojiUnicodeSupported, {
isHorceRacingSkinToneComboEmoji, isHorceRacingSkinToneComboEmoji,
isPersonZwjEmoji, isPersonZwjEmoji,
} from '~/emoji/support/is_emoji_unicode_supported'; } from '~/emoji/support/is_emoji_unicode_supported';
import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants';
const emptySupportMap = { const emptySupportMap = {
personZwj: false, personZwj: false,
@ -436,14 +437,28 @@ describe('emoji', () => {
it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => { it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => {
const search = searchEmoji(input); const search = searchEmoji(input);
const expected = Object.keys(validEmoji).map((name) => { const expected = Object.keys(validEmoji)
return { .map((name) => {
emoji: mockEmojiData[name], let score = NEUTRAL_INTENT_MULTIPLIER;
field: 'd',
fieldValue: mockEmojiData[name].d, // Positive intent value retrieved from ~/emoji/intents.json
score: 0, 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); expect(search).toEqual(expected);
}); });
@ -457,7 +472,7 @@ describe('emoji', () => {
name: 'atom', name: 'atom',
field: 'e', field: 'e',
fieldValue: 'atom', fieldValue: 'atom',
score: 0, score: NEUTRAL_INTENT_MULTIPLIER,
}, },
], ],
], ],
@ -469,7 +484,7 @@ describe('emoji', () => {
name: 'atom', name: 'atom',
field: 'alias', field: 'alias',
fieldValue: 'atom_symbol', fieldValue: 'atom_symbol',
score: 4, score: 16,
}, },
], ],
], ],
@ -481,7 +496,7 @@ describe('emoji', () => {
name: 'atom', name: 'atom',
field: 'alias', field: 'alias',
fieldValue: 'atom_symbol', fieldValue: 'atom_symbol',
score: 0, score: NEUTRAL_INTENT_MULTIPLIER,
}, },
], ],
], ],
@ -509,7 +524,7 @@ describe('emoji', () => {
{ {
name: 'atom', name: 'atom',
field: 'd', field: 'd',
score: 0, score: NEUTRAL_INTENT_MULTIPLIER,
}, },
], ],
], ],
@ -521,7 +536,7 @@ describe('emoji', () => {
{ {
name: 'atom', name: 'atom',
field: 'd', field: 'd',
score: 0, score: NEUTRAL_INTENT_MULTIPLIER,
}, },
], ],
], ],
@ -533,7 +548,7 @@ describe('emoji', () => {
{ {
name: 'grey_question', name: 'grey_question',
field: 'name', field: 'name',
score: 5, score: 32,
}, },
], ],
], ],
@ -544,7 +559,7 @@ describe('emoji', () => {
{ {
name: 'grey_question', name: 'grey_question',
field: 'd', field: 'd',
score: 24, score: 16777216,
}, },
], ],
], ],
@ -552,15 +567,15 @@ describe('emoji', () => {
'searching with query "heart"', 'searching with query "heart"',
'heart', 'heart',
[ [
{
name: 'black_heart',
field: 'd',
score: 6,
},
{ {
name: 'heart', name: 'heart',
field: 'name', 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"', 'searching with query "HEART"',
'HEART', 'HEART',
[ [
{
name: 'black_heart',
field: 'd',
score: 6,
},
{ {
name: 'heart', name: 'heart',
field: 'name', 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"', 'searching with query "star"',
'star', 'star',
[ [
{
name: 'custard',
field: 'd',
score: 2,
},
{ {
name: 'star', name: 'star',
field: 'name', 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: 10, fieldValue: '', emoji: { name: 'a' } },
{ score: 5, fieldValue: '', emoji: { name: 'b' } }, { 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: 5, fieldValue: '', emoji: { name: 'b' } },
{ score: 10, fieldValue: '', emoji: { name: 'a' } }, { score: 10, fieldValue: '', emoji: { name: 'a' } },
], ],
@ -630,25 +661,25 @@ describe('emoji', () => {
[ [
'should correctly sort by fieldValue', 'should correctly sort by fieldValue',
[ [
{ score: 0, fieldValue: 'y', emoji: { name: 'b' } }, { score: 1, fieldValue: 'y', emoji: { name: 'b' } },
{ score: 0, fieldValue: 'x', emoji: { name: 'a' } }, { score: 1, fieldValue: 'x', emoji: { name: 'a' } },
{ score: 0, fieldValue: 'z', emoji: { name: 'c' } }, { score: 1, fieldValue: 'z', emoji: { name: 'c' } },
], ],
[ [
{ score: 0, fieldValue: 'x', emoji: { name: 'a' } }, { score: 1, fieldValue: 'x', emoji: { name: 'a' } },
{ score: 0, fieldValue: 'y', emoji: { name: 'b' } }, { score: 1, fieldValue: 'y', emoji: { name: 'b' } },
{ score: 0, fieldValue: 'z', emoji: { name: 'c' } }, { score: 1, fieldValue: 'z', emoji: { name: 'c' } },
], ],
], ],
[ [
'should correctly sort by score and then by fieldValue (in order)', 'should correctly sort by score and then by fieldValue (in order)',
[ [
{ score: 5, fieldValue: 'y', emoji: { name: 'c' } }, { 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: 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: 'x', emoji: { name: 'b' } },
{ score: 5, fieldValue: 'y', emoji: { name: 'c' } }, { score: 5, fieldValue: 'y', emoji: { name: 'c' } },
], ],
@ -656,7 +687,7 @@ describe('emoji', () => {
]; ];
it.each(testCases)('%s', (_, scoredItems, expected) => { 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) remove_repository(project)
end end
before do
allow(Gitlab::Ci::RunnerUpgradeCheck.instance)
.to receive(:check_runner_upgrade_status)
.and_return(:not_available)
end
describe do describe do
before do before do
sign_in(admin) sign_in(admin)

View File

@ -1,193 +1,97 @@
import Vue, { nextTick } from 'vue'; import Vue from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import Vuex from 'vuex';
import { projectData, branches } from 'jest/ide/mock_data'; 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 NewMergeRequestOption from '~/ide/components/commit_sidebar/new_merge_request_option.vue';
import { PERMISSION_CREATE_MR } from '~/ide/constants';
import { createStore } from '~/ide/stores'; import { createStore } from '~/ide/stores';
import { import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
COMMIT_TO_CURRENT_BRANCH,
COMMIT_TO_NEW_BRANCH,
} from '~/ide/stores/modules/commit/constants';
describe('create new MR checkbox', () => { Vue.use(Vuex);
describe('NewMergeRequestOption component', () => {
let store; let store;
let vm; let wrapper;
const setMR = () => { const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
vm.$store.state.currentMergeRequestId = '1'; const findFieldset = () => wrapper.findByTestId('new-merge-request-fieldset');
vm.$store.state.projects[store.state.currentProjectId].mergeRequests[ const findTooltip = () => getBinding(findFieldset().element, 'gl-tooltip');
store.state.currentMergeRequestId
] = { foo: 'bar' };
};
const setPermissions = (permissions) => { const createComponent = ({
store.state.projects[store.state.currentProjectId].userPermissions = permissions; shouldHideNewMrOption = false,
}; shouldDisableNewMrOption = false,
shouldCreateMR = false,
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(() => {
store = createStore(); store = createStore();
store.state.currentProjectId = 'abcproject'; wrapper = shallowMountExtended(NewMergeRequestOption, {
store: {
const proj = JSON.parse(JSON.stringify(projectData)); ...store,
proj.userPermissions[PERMISSION_CREATE_MR] = true; getters: {
Vue.set(store.state.projects, 'abcproject', proj); 'commit/shouldHideNewMrOption': shouldHideNewMrOption,
}); 'commit/shouldDisableNewMrOption': shouldDisableNewMrOption,
'commit/shouldCreateMR': shouldCreateMR,
},
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
}); });
describe('for default branch', () => { describe('when the `shouldHideNewMrOption` getter returns false', () => {
describe('is rendered when pushing to a new branch', () => { beforeEach(() => {
beforeEach(() => { createComponent();
createComponent({ jest.spyOn(store, 'dispatch').mockImplementation();
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('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(() => { beforeEach(() => {
createComponent({ createComponent({
currentBranchId: 'main', shouldDisableNewMrOption: true,
createNewBranch: false,
}); });
}); });
it('has NO new MR', () => { it('disables the new MR checkbox', () => {
expect(vm.$el.textContent).toBe(''); expect(findCheckbox().attributes('disabled')).toBe('true');
}); });
it('has new MR', async () => { it('adds `is-disabled` class to the fieldset', () => {
setMR(); expect(findFieldset().classes()).toContain('is-disabled');
});
await nextTick(); it('shows a tooltip', () => {
expect(vm.$el.textContent).toBe(''); expect(findTooltip().value).toBe(wrapper.vm.$options.i18n.tooltipText);
}); });
}); });
}); });
describe('for protected branch', () => { describe('when the `shouldHideNewMrOption` getter returns true', () => {
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', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
currentBranchId: 'regular', shouldHideNewMrOption: true,
}); });
}); });
it('is rendered if no MR exists', () => { it("doesn't render the new MR checkbox", () => {
expect(vm.$el.textContent).not.toBe(''); 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 RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.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 RunnerStats from '~/runner/components/stat/runner_stats.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
@ -50,6 +51,8 @@ import {
runnersDataPaginated, runnersDataPaginated,
onlineContactTimeoutSecs, onlineContactTimeoutSecs,
staleTimeoutSecs, staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
} from '../mock_data'; } from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
@ -78,6 +81,7 @@ describe('AdminRunnersApp', () => {
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
@ -106,6 +110,8 @@ describe('AdminRunnersApp', () => {
localMutations, localMutations,
onlineContactTimeoutSecs, onlineContactTimeoutSecs,
staleTimeoutSecs, staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
...provide, ...provide,
}, },
...options, ...options,
@ -457,12 +463,28 @@ describe('AdminRunnersApp', () => {
runners: { nodes: [] }, runners: { nodes: [] },
}, },
}); });
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
}); });
it('shows a message for no results', async () => { it('shows an empty state', () => {
expect(wrapper.text()).toContain('No runners found'); 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 { mount } from '@vue/test-utils';
import RunnerStatusCell from '~/runner/components/cells/runner_status_cell.vue'; 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'; import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE } from '~/runner/constants';
describe('RunnerTypeCell', () => { describe('RunnerStatusCell', () => {
let wrapper; let wrapper;
const findBadgeAt = (i) => wrapper.findAllComponents(GlBadge).at(i); const findStatusBadge = () => wrapper.findComponent(RunnerStatusBadge);
const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge);
const createComponent = ({ runner = {} } = {}) => { const createComponent = ({ runner = {} } = {}) => {
wrapper = mount(RunnerStatusCell, { wrapper = mount(RunnerStatusCell, {
@ -29,7 +32,7 @@ describe('RunnerTypeCell', () => {
createComponent(); createComponent();
expect(wrapper.text()).toMatchInterpolatedText('online'); expect(wrapper.text()).toMatchInterpolatedText('online');
expect(findBadgeAt(0).text()).toBe('online'); expect(findStatusBadge().text()).toBe('online');
}); });
it('Displays offline status', () => { it('Displays offline status', () => {
@ -40,7 +43,7 @@ describe('RunnerTypeCell', () => {
}); });
expect(wrapper.text()).toMatchInterpolatedText('offline'); expect(wrapper.text()).toMatchInterpolatedText('offline');
expect(findBadgeAt(0).text()).toBe('offline'); expect(findStatusBadge().text()).toBe('offline');
}); });
it('Displays paused status', () => { it('Displays paused status', () => {
@ -52,9 +55,7 @@ describe('RunnerTypeCell', () => {
}); });
expect(wrapper.text()).toMatchInterpolatedText('online paused'); expect(wrapper.text()).toMatchInterpolatedText('online paused');
expect(findPausedBadge().text()).toBe('paused');
expect(findBadgeAt(0).text()).toBe('online');
expect(findBadgeAt(1).text()).toBe('paused');
}); });
it('Is empty when data is missing', () => { 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 RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.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 RunnerStats from '~/runner/components/stat/runner_stats.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
@ -48,6 +49,8 @@ import {
groupRunnersCountData, groupRunnersCountData,
onlineContactTimeoutSecs, onlineContactTimeoutSecs,
staleTimeoutSecs, staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
} from '../mock_data'; } from '../mock_data';
Vue.use(VueApollo); Vue.use(VueApollo);
@ -75,6 +78,7 @@ describe('GroupRunnersApp', () => {
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState);
const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`)); const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`));
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
@ -103,6 +107,8 @@ describe('GroupRunnersApp', () => {
provide: { provide: {
onlineContactTimeoutSecs, onlineContactTimeoutSecs,
staleTimeoutSecs, staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
}, },
}); });
}; };
@ -388,8 +394,8 @@ describe('GroupRunnersApp', () => {
await waitForPromises(); await waitForPromises();
}); });
it('shows a message for no results', async () => { it('shows an empty state', async () => {
expect(wrapper.text()).toContain('No runners found'); 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 onlineContactTimeoutSecs = 2 * 60 * 60;
export const staleTimeoutSecs = 7889238; // Ruby's `3.months` export const staleTimeoutSecs = 7889238; // Ruby's `3.months`
export const emptyStateSvgPath = 'emptyStateSvgPath.svg';
export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg';
export { export {
runnersData, runnersData,
runnersDataPaginated, runnersDataPaginated,

View File

@ -5,6 +5,7 @@ import {
fromUrlQueryToSearch, fromUrlQueryToSearch,
fromSearchToUrl, fromSearchToUrl,
fromSearchToVariables, fromSearchToVariables,
isSearchFiltered,
} from '~/runner/runner_search_utils'; } from '~/runner/runner_search_utils';
describe('search_params.js', () => { describe('search_params.js', () => {
@ -14,6 +15,7 @@ describe('search_params.js', () => {
urlQuery: '', urlQuery: '',
search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
isDefault: true,
}, },
{ {
name: 'a single status', name: 'a single status',
@ -268,7 +270,7 @@ describe('search_params.js', () => {
describe('fromSearchToUrl', () => { describe('fromSearchToUrl', () => {
examples.forEach(({ name, urlQuery, search }) => { examples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a url`, () => { 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 search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/`; 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', () => { 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 search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/?unrelated=UNRELATED`; 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) { if (!newBranch) {
const option = await screen.findByLabelText(/Commit to .+ branch/); const option = await screen.findByLabelText(/Commit to .+ branch/);
option.click(); await option.click();
} else { } else {
const option = await screen.findByLabelText('Create a new branch'); const option = await screen.findByLabelText('Create a new branch');
option.click(); await option.click();
const branchNameInput = await screen.findByTestId('ide-new-branch-name'); const branchNameInput = await screen.findByTestId('ide-new-branch-name');
fireEvent.input(branchNameInput, { target: { value: newBranchName } }); fireEvent.input(branchNameInput, { target: { value: newBranchName } });

View File

@ -27,11 +27,9 @@ RSpec.describe ResolvesGroups do
let_it_be(:lookahead_fields) do let_it_be(:lookahead_fields) do
<<~FIELDS <<~FIELDS
contacts { nodes { id } }
containerRepositoriesCount containerRepositoriesCount
customEmoji { nodes { id } } customEmoji { nodes { id } }
fullPath fullPath
organizations { nodes { id } }
path path
dependencyProxyBlobCount dependencyProxyBlobCount
dependencyProxyBlobs { nodes { fileName } } 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 end
it 'returns the data in format' do 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/', runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token, registration_token: Gitlab::CurrentSettings.runners_registration_token,
online_contact_timeout_secs: 7200, 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
end end
@ -130,14 +132,16 @@ RSpec.describe Ci::RunnersHelper do
let(:group) { create(:group) } let(:group) { create(:group) }
it 'returns group data to render a runner list' do 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, registration_token: group.runners_token,
group_id: group.id, group_id: group.id,
group_full_path: group.full_path, group_full_path: group.full_path,
runner_install_help_page: 'https://docs.gitlab.com/runner/install/', runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
online_contact_timeout_secs: 7200, 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
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 context 'when the limit is exceeded' do
before do before do
stub_application_setting(pipeline_limit_per_project_user_sha: 1) stub_application_setting(pipeline_limit_per_project_user_sha: 1)
stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: false)
end end
it 'does not persist the pipeline' do 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, class: described_class.name,
project_id: project.id, project_id: project.id,
subscription_plan: project.actual_plan_name, 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, class: described_class.name,
project_id: project.id, project_id: project.id,
subscription_plan: project.actual_plan_name, 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 build.execute_hooks
end 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 end
context 'without project hooks' do context 'without project hooks' do

View File

@ -3056,7 +3056,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end end
describe 'hooks trigerring' do 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[ %i[
enqueue enqueue
@ -3076,7 +3076,19 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
it 'schedules a new PipelineHooksWorker job' do it 'schedules a new PipelineHooksWorker job' do
expect(PipelineHooksWorker).to receive(:perform_async).with(pipeline.id) 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 end
end end
@ -3636,6 +3648,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
pipeline.succeed! pipeline.succeed!
end end
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 end
context 'with failed pipeline' do context 'with failed pipeline' do
@ -3656,6 +3680,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
pipeline.drop pipeline.drop
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.drop
end
end
end end
context 'with skipped pipeline' do context 'with skipped pipeline' do

View File

@ -11,6 +11,7 @@ RSpec.describe Ci::CreatePipelineService, :freeze_time, :clean_gitlab_redis_rate
before do before do
stub_ci_pipeline_yaml_file(gitlab_ci_yaml) stub_ci_pipeline_yaml_file(gitlab_ci_yaml)
stub_application_setting(pipeline_limit_per_project_user_sha: 1) stub_application_setting(pipeline_limit_per_project_user_sha: 1)
stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: false)
end end
context 'when user is under the limit' do 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' include_examples 'fails with outdated reference message'
end end
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
end end

View File

@ -62,7 +62,7 @@ RSpec.shared_examples 'shows and resets runner registration token' do
end end
end end
RSpec.shared_examples 'shows no runners' do RSpec.shared_examples 'shows no runners registered' do
it 'shows counts with 0' do it 'shows counts with 0' do
expect(page).to have_text "Online runners 0" expect(page).to have_text "Online runners 0"
expect(page).to have_text "Offline runners 0" expect(page).to have_text "Offline runners 0"
@ -70,13 +70,19 @@ RSpec.shared_examples 'shows no runners' do
end end
it 'shows "no runners" message' do 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
end end
RSpec.shared_examples 'shows runner in list' do RSpec.shared_examples 'shows runner in list' do
it 'does not show empty state' 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 end
it 'shows runner row' do it 'shows runner row' do

View File

@ -25,6 +25,16 @@ RSpec.describe PipelineHooksWorker do
.not_to raise_error .not_to raise_error
end end
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 end
it_behaves_like 'worker with data consistency', it_behaves_like 'worker with data consistency',

View File

@ -21,6 +21,20 @@ RSpec.describe PipelineNotificationWorker, :mailer do
subject.perform(non_existing_record_id) subject.perform(non_existing_record_id)
end 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', it_behaves_like 'worker with data consistency',
described_class, described_class,
data_consistency: :delayed data_consistency: :delayed

View File

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