diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-10 18:09:22 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-10 18:09:22 +0300 |
commit | 37140013714814d8ffe662a372697c56eea2fde0 (patch) | |
tree | b25c0bfc62da359f97b8b3742007c07723242f93 | |
parent | 948023c9c900344aa1e2f334bcaae5a194873b0d (diff) |
Add latest changes from gitlab-org/gitlab@master
100 files changed, 2022 insertions, 435 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 14352baf9ba..e754d61ec81 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -70d6aa021ebfc05d9d727a7eb4c9ff4782db4c30 +30b922784b9d0492ba525a35ec09782dd2bcace3 diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index aa735df7da5..a030797c698 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -3,9 +3,9 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import { uniq } from 'lodash'; +import { getEmojiScoreWithIntent } from '~/emoji/utils'; import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils'; import * as Emoji from '~/emoji'; - import { dispose, fixTitle } from '~/tooltips'; import createFlash from './flash'; import axios from './lib/utils/axios_utils'; @@ -559,13 +559,45 @@ export class AwardsHandler { } } + getEmojiScore(emojis, value) { + const elem = $(value).find('[data-name]').get(0); + const emoji = emojis.filter((x) => x.emoji.name === elem.dataset.name)[0]; + elem.dataset.score = emoji.score; + + return emoji.score; + } + + sortEmojiElements(emojis, $elements) { + const scores = new WeakMap(); + + return $elements.sort((a, b) => { + let aScore = scores.get(a); + let bScore = scores.get(b); + + if (!aScore) { + aScore = this.getEmojiScore(emojis, a); + scores.set(a, aScore); + } + + if (!bScore) { + bScore = this.getEmojiScore(emojis, b); + scores.set(b, bScore); + } + + return aScore - bScore; + }); + } + findMatchingEmojiElements(query) { - const emojiMatches = this.emoji.searchEmoji(query).map((x) => x.emoji.name); + const matchingEmoji = this.emoji + .searchEmoji(query) + .map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) })); + const matchingEmojiNames = matchingEmoji.map((x) => x.emoji.name); const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]'); const $matchingElements = $emojiElements.filter( - (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0, + (i, elm) => matchingEmojiNames.indexOf(elm.dataset.name) >= 0, ); - return $matchingElements.closest('li').clone(); + return this.sortEmojiElements(matchingEmoji, $matchingElements.closest('li').clone()); } /* showMenuElement and hideMenuElement are performance optimizations. We use diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js index a6eb4256561..7970a932095 100644 --- a/app/assets/javascripts/emoji/constants.js +++ b/app/assets/javascripts/emoji/constants.js @@ -19,3 +19,5 @@ export const CATEGORY_ROW_HEIGHT = 37; export const CACHE_VERSION_KEY = 'gl-emoji-map-version'; export const CACHE_KEY = 'gl-emoji-map'; + +export const NEUTRAL_INTENT_MULTIPLIER = 1; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 4fdcdcc1b04..b9392fabcbd 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash'; import emojiRegexFactory from 'emoji-regex'; import emojiAliases from 'emojis/aliases.json'; import { setAttributes } from '~/lib/utils/dom_utils'; +import { getEmojiScoreWithIntent } from '~/emoji/utils'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; @@ -144,6 +145,11 @@ function getNameMatch(emoji, query) { return null; } +// Sort emoji by emoji score falling back to a string comparison +export function sortEmoji(a, b) { + return a.score - b.score || a.fieldValue.localeCompare(b.fieldValue); +} + export function searchEmoji(query) { const lowercaseQuery = query ? `${query}`.toLowerCase() : ''; @@ -156,16 +162,14 @@ export function searchEmoji(query) { getDescriptionMatch(emoji, lowercaseQuery), getAliasMatch(emoji, matchingAliases), getNameMatch(emoji, lowercaseQuery), - ].filter(Boolean); + ] + .filter(Boolean) + .map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) })); return minBy(matches, (x) => x.score); }) - .filter(Boolean); -} - -export function sortEmoji(items) { - // Sort results by index of and string comparison - return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue)); + .filter(Boolean) + .sort(sortEmoji); } export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP); diff --git a/app/assets/javascripts/emoji/utils.js b/app/assets/javascripts/emoji/utils.js new file mode 100644 index 00000000000..eb3dcea73c0 --- /dev/null +++ b/app/assets/javascripts/emoji/utils.js @@ -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; +} diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 146255df31f..d4dafbdc94f 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -897,7 +897,7 @@ GfmAutoComplete.Emoji = { return Emoji.searchEmoji(query); }, sorter(items) { - return Emoji.sortEmoji(items); + return items.sort(Emoji.sortEmoji); }, }; // Team Members diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue index 43bf2e1a90c..0a8fec49aac 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; import { createNamespacedHelpers } from 'vuex'; import { s__ } from '~/locale'; @@ -8,19 +8,20 @@ const { mapActions: mapCommitActions, mapGetters: mapCommitGetters } = createNam ); export default { + components: { GlFormCheckbox }, directives: { GlTooltip: GlTooltipDirective, }, + i18n: { + newMrText: s__('IDE|Start a new merge request'), + tooltipText: s__( + 'IDE|This option is disabled because you are not allowed to create merge requests in this project.', + ), + }, computed: { ...mapCommitGetters(['shouldHideNewMrOption', 'shouldDisableNewMrOption', 'shouldCreateMR']), tooltipText() { - if (this.shouldDisableNewMrOption) { - return s__( - 'IDE|This option is disabled because you are not allowed to create merge requests in this project.', - ); - } - - return ''; + return this.shouldDisableNewMrOption ? this.$options.i18n.tooltipText : null; }, }, methods: { @@ -30,22 +31,23 @@ export default { </script> <template> - <fieldset v-if="!shouldHideNewMrOption"> - <hr class="my-2" /> - <label - v-gl-tooltip="tooltipText" - class="mb-0 js-ide-commit-new-mr" - :class="{ 'is-disabled': shouldDisableNewMrOption }" + <fieldset + v-if="!shouldHideNewMrOption" + v-gl-tooltip="tooltipText" + data-testid="new-merge-request-fieldset" + class="js-ide-commit-new-mr" + :class="{ 'is-disabled': shouldDisableNewMrOption }" + > + <hr class="gl-mt-3 gl-mb-4" /> + + <gl-form-checkbox + :disabled="shouldDisableNewMrOption" + :checked="shouldCreateMR" + @change="toggleShouldCreateMR" > - <input - :disabled="shouldDisableNewMrOption" - :checked="shouldCreateMR" - type="checkbox" - @change="toggleShouldCreateMR" - /> - <span class="gl-ml-3 ide-option-label"> - {{ __('Start a new merge request') }} + <span class="ide-option-label"> + {{ $options.i18n.newMrText }} </span> - </label> + </gl-form-checkbox> </fieldset> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 870355e884e..bd5d28dbb56 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -1,8 +1,20 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { + GlTooltipDirective, + GlFormRadio, + GlFormRadioGroup, + GlFormGroup, + GlFormInput, +} from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; export default { + components: { + GlFormRadio, + GlFormRadioGroup, + GlFormGroup, + GlFormInput, + }, directives: { GlTooltip: GlTooltipDirective, }, @@ -51,35 +63,42 @@ export default { </script> <template> - <fieldset> - <label + <fieldset class="gl-mb-2"> + <gl-form-radio-group v-gl-tooltip="tooltipTitle" + :checked="commitAction" :class="{ 'is-disabled': disabled, }" > - <input + <gl-form-radio :value="value" - :checked="commitAction === value" :disabled="disabled" - type="radio" name="commit-action" data-qa-selector="commit_type_radio" - @change="updateCommitAction($event.target.value)" - /> - <span class="gl-ml-3"> - <span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot> - </span> - </label> - <div v-if="commitAction === value && showInput" class="ide-commit-new-branch"> - <input + @change="updateCommitAction(value)" + > + <span v-if="label" class="ide-option-label"> + {{ label }} + </span> + <slot v-else></slot> + </gl-form-radio> + </gl-form-radio-group> + + <gl-form-group + v-if="commitAction === value && showInput" + :label="placeholderBranchName" + :label-sr-only="true" + class="gl-ml-6 gl-mb-0" + > + <gl-form-input :placeholder="placeholderBranchName" :value="newBranchName" + :disabled="disabled" data-testid="ide-new-branch-name" - type="text" - class="form-control monospace" - @input="updateBranchName($event.target.value)" + class="gl-font-monospace" + @input="updateBranchName($event)" /> - </div> + </gl-form-group> </fieldset> </template> diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index c2bb635e056..a90ef2d3530 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -10,6 +10,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerBulkDelete from '../components/runner_bulk_delete.vue'; import RunnerList from '../components/runner_list.vue'; +import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; import RunnerName from '../components/runner_name.vue'; import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; @@ -35,6 +36,7 @@ import { fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, + isSearchFiltered, } from '../runner_search_utils'; import { captureException } from '../sentry_utils'; @@ -91,6 +93,7 @@ export default { RunnerFilteredSearchBar, RunnerBulkDelete, RunnerList, + RunnerListEmptyState, RunnerName, RunnerStats, RunnerPagination, @@ -98,7 +101,7 @@ export default { RunnerActionsCell, }, mixins: [glFeatureFlagMixin()], - inject: ['localMutations'], + inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath', 'localMutations'], props: { registrationToken: { type: String, @@ -190,6 +193,9 @@ export default { // Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981 return this.glFeatures.adminRunnersBulkDelete; }, + isSearchFiltered() { + return isSearchFiltered(this.search); + }, }, watch: { search: { @@ -298,9 +304,13 @@ export default { :stale-runners-count="staleRunnersTotal" /> - <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> - {{ __('No runners found') }} - </div> + <runner-list-empty-state + v-if="noRunnersFound" + :registration-token="registrationToken" + :is-search-filtered="isSearchFiltered" + :svg-path="emptyStateSvgPath" + :filtered-svg-path="emptyStateFilteredSvgPath" + /> <template v-else> <runner-bulk-delete v-if="isBulkDeleteEnabled" /> <runner-list diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js index b1d8442bb32..7bb6cd5689e 100644 --- a/app/assets/javascripts/runner/admin_runners/index.js +++ b/app/assets/javascripts/runner/admin_runners/index.js @@ -34,6 +34,8 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { registrationToken, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, } = el.dataset; const { cacheConfig, typeDefs, localMutations } = createLocalState(); @@ -50,6 +52,8 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, }, render(h) { return h(AdminRunnersApp, { diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue index 93f86ae2a2c..a48db9f8ac8 100644 --- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue @@ -7,6 +7,8 @@ import RunnerPausedBadge from '../runner_paused_badge.vue'; export default { components: { RunnerStatusBadge, + RunnerUpgradeStatusBadge: () => + import('ee_component/runner/components/runner_upgrade_status_badge.vue'), RunnerPausedBadge, }, directives: { @@ -33,6 +35,11 @@ export default { size="sm" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> + <runner-upgrade-status-badge + :runner="runner" + size="sm" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> <runner-paused-badge v-if="paused" size="sm" diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index dcfd4b84dd2..f1f99c728c5 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -12,7 +12,7 @@ import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; const defaultFields = [ - tableField({ key: 'status', label: s__('Runners|Status') }), + tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }), tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'version', label: __('Version') }), tableField({ key: 'jobCount', label: __('Jobs') }), diff --git a/app/assets/javascripts/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/runner/components/runner_list_empty_state.vue new file mode 100644 index 00000000000..cddd51a351c --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_list_empty_state.vue @@ -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> diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql index 5d0450e7418..61bfe03bf6e 100644 --- a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql @@ -1,4 +1,4 @@ -#import "~/runner/graphql/list/list_item.fragment.graphql" +#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" #import "~/graphql_shared/fragments/page_info.fragment.graphql" query getRunners( diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql index b4f2b5cd8c8..8755636a7ad 100644 --- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql @@ -1,4 +1,4 @@ -#import "~/runner/graphql/list/list_item.fragment.graphql" +#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" #import "~/graphql_shared/fragments/page_info.fragment.graphql" query getGroupRunners( diff --git a/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql index 620c18c5bc0..19a5a48ea75 100644 --- a/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql @@ -1,20 +1,5 @@ +#import "./list_item_shared.fragment.graphql" + fragment ListItem on CiRunner { - __typename - id - description - runnerType - shortSha - version - revision - ipAddress - active - locked - jobCount - tagList - contactedAt - status(legacyMode: null) - userPermissions { - updateRunner - deleteRunner - } + ...ListItemShared } diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql new file mode 100644 index 00000000000..cf925359ffb --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql @@ -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 + } +} diff --git a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue new file mode 100644 index 00000000000..c336e091fdf --- /dev/null +++ b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue @@ -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> diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js new file mode 100644 index 00000000000..d1b87c8e427 --- /dev/null +++ b/app/assets/javascripts/runner/group_runner_show/index.js @@ -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, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index b5bd4b111fd..641b3a8f560 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -8,6 +8,7 @@ import { fetchPolicies } from '~/lib/graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; +import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; import RunnerName from '../components/runner_name.vue'; import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; @@ -31,6 +32,7 @@ import { fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, + isSearchFiltered, } from '../runner_search_utils'; import { captureException } from '../sentry_utils'; @@ -86,12 +88,14 @@ export default { RegistrationDropdown, RunnerFilteredSearchBar, RunnerList, + RunnerListEmptyState, RunnerName, RunnerStats, RunnerPagination, RunnerTypeTabs, RunnerActionsCell, }, + inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], props: { registrationToken: { type: String, @@ -196,6 +200,9 @@ export default { filteredSearchNamespace() { return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; }, + isSearchFiltered() { + return isSearchFiltered(this.search); + }, }, watch: { search: { @@ -299,9 +306,13 @@ export default { :stale-runners-count="staleRunnersTotal" /> - <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> - {{ __('No runners found') }} - </div> + <runner-list-empty-state + v-if="noRunnersFound" + :registration-token="registrationToken" + :is-search-filtered="isSearchFiltered" + :svg-path="emptyStateSvgPath" + :filtered-svg-path="emptyStateFilteredSvgPath" + /> <template v-else> <runner-list :runners="runners.items" :loading="runnersLoading"> <template #runner-name="{ runner }"> diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js index 0dade30f820..feed6b0ceb7 100644 --- a/app/assets/javascripts/runner/group_runners/index.js +++ b/app/assets/javascripts/runner/group_runners/index.js @@ -22,6 +22,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => { groupRunnersLimitedCount, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, } = el.dataset; const apolloProvider = new VueApollo({ @@ -36,6 +38,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => { groupId, onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10), staleTimeoutSecs: parseInt(staleTimeoutSecs, 10), + emptyStateSvgPath, + emptyStateFilteredSvgPath, }, render(h) { return h(GroupRunnersApp, { diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index 0d688ed65ef..e01878f355a 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -236,3 +236,17 @@ export const fromSearchToVariables = ({ ...paginationVariables, }; }; + +/** + * Decides whether or not a search object is the "default" or empty. + * + * A search is filtered if the user has entered filtering criteria. + * + * @param {Object} search + * @returns true if this search is filtered, false otherwise + */ +export const isSearchFiltered = ({ runnerType = null, filters = [], pagination = {} } = {}) => { + return Boolean( + runnerType !== null || filters?.length !== 0 || (pagination && pagination?.page !== 1), + ); +}; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index 489d4afa41f..72dcc16b57a 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -302,9 +302,11 @@ export default { <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal"> {{ __('None') }} - <gl-button - class="gl-ml-2" + class="gl-ml-2 gl-reset-color!" href="#" + category="tertiary" variant="link" + size="small" data-testid="unassigned-users" @click="updateAlertAssignees(currentUser)" > diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 32439c13a9d..036cec15935 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -247,8 +247,8 @@ z-index: 600; width: $contextual-sidebar-width; top: $header-height; - @include gl-bg-gray-10; - border-right: 1px solid $gray-50; + background-color: $contextual-sidebar-bg-color; + border-right: 1px solid $contextual-sidebar-border-color; transform: translate3d(0, 0, 0); &.sidebar-collapsed-desktop { @@ -411,7 +411,7 @@ .toggle-sidebar-button, .close-nav-button { @include side-panel-toggle; - @include gl-bg-gray-10; + background-color: $contextual-sidebar-bg-color; position: fixed; bottom: 0; width: #{$contextual-sidebar-width - 1px}; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 72e7722cd6d..d537381b99d 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -357,6 +357,8 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor); /* * UI elements */ +$contextual-sidebar-bg-color: #f5f5f5; +$contextual-sidebar-border-color: #e9e9e9; $border-color: $gray-100; $shadow-color: $t-gray-a-08; $well-expand-item: #e8f2f7 !default; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 6c270852e53..a4a82fdcef3 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -563,24 +563,11 @@ $ide-commit-header-height: 48px; } .ide-commit-options { - label { - font-weight: normal; - - &.is-disabled { - .ide-option-label { - text-decoration: line-through; - } + .is-disabled { + .ide-option-label { + text-decoration: line-through; } } - - .form-text.text-muted { - margin-top: 0; - line-height: 0; - } -} - -.ide-commit-new-branch { - margin-left: 25px; } .ide-sidebar-link { diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 7792959968f..4cefa60b12a 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -1034,8 +1034,8 @@ input { z-index: 600; width: 256px; top: var(--header-height, 48px); - background-color: #1f1f1f; - border-right: 1px solid #303030; + background-color: #f5f5f5; + border-right: 1px solid #e9e9e9; transform: translate3d(0, 0, 0); } .nav-sidebar.sidebar-collapsed-desktop { @@ -1402,7 +1402,7 @@ input { color: #999; display: flex; align-items: center; - background-color: #1f1f1f; + background-color: #f5f5f5; position: fixed; bottom: 0; width: 255px; @@ -1792,6 +1792,7 @@ body.gl-dark { .toggle-sidebar-button, .close-nav-button { background-color: #262626; + border-right: 1px solid #303030; } .nav-sidebar li a { color: var(--gray-600); diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 3e32de56e90..6753f0cb46b 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -1019,8 +1019,8 @@ input { z-index: 600; width: 256px; top: var(--header-height, 48px); - background-color: #fafafa; - border-right: 1px solid #f0f0f0; + background-color: #f5f5f5; + border-right: 1px solid #e9e9e9; transform: translate3d(0, 0, 0); } .nav-sidebar.sidebar-collapsed-desktop { @@ -1387,7 +1387,7 @@ input { color: #666; display: flex; align-items: center; - background-color: #fafafa; + background-color: #f5f5f5; position: fixed; bottom: 0; width: 255px; diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index fb001504eed..34bb4925249 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -45,6 +45,7 @@ .toggle-sidebar-button, .close-nav-button { background-color: darken($gray-50, 4%); + border-right: 1px solid $gray-50; } .nav-sidebar { diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 8d687bf3c2c..aeb54527c69 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -41,3 +41,5 @@ class Groups::RunnersController < Groups::ApplicationController params.require(:runner).permit(Ci::Runner::FORM_EDITABLE) end end + +Groups::RunnersController.prepend_mod diff --git a/app/finders/crm/contacts_finder.rb b/app/finders/crm/contacts_finder.rb index c2d44bec27b..f7c2625f788 100644 --- a/app/finders/crm/contacts_finder.rb +++ b/app/finders/crm/contacts_finder.rb @@ -6,6 +6,8 @@ # current_user - user performing the action. Must have the correct permission level for the group. # params: # group: Group, required +# search: String, optional +# state: CustomerRelations::ContactStateEnum, optional module Crm class ContactsFinder include Gitlab::Allowable @@ -21,7 +23,10 @@ module Crm def execute return CustomerRelations::Contact.none unless root_group - root_group.contacts + contacts = root_group.contacts + contacts = by_state(contacts) + contacts = by_search(contacts) + contacts.sort_by_name end private @@ -35,5 +40,25 @@ module Crm group end end + + def by_search(contacts) + return contacts unless search? + + contacts.search(params[:search]) + end + + def by_state(contacts) + return contacts unless state? + + contacts.search_by_state(params[:state]) + end + + def search? + params[:search].present? + end + + def state? + params[:state].present? + end end end diff --git a/app/finders/crm/organizations_finder.rb b/app/finders/crm/organizations_finder.rb new file mode 100644 index 00000000000..1a3df05aa11 --- /dev/null +++ b/app/finders/crm/organizations_finder.rb @@ -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 diff --git a/app/graphql/resolvers/concerns/resolves_groups.rb b/app/graphql/resolvers/concerns/resolves_groups.rb index c451d4e7936..2a3dce80057 100644 --- a/app/graphql/resolvers/concerns/resolves_groups.rb +++ b/app/graphql/resolvers/concerns/resolves_groups.rb @@ -18,11 +18,9 @@ module ResolvesGroups def preloads { - contacts: [:contacts], container_repositories_count: [:container_repositories], custom_emoji: [:custom_emoji], full_path: [:route], - organizations: [:organizations], path: [:route], dependency_proxy_blob_count: [:dependency_proxy_blobs], dependency_proxy_blobs: [:dependency_proxy_blobs], diff --git a/app/graphql/resolvers/crm/contacts_resolver.rb b/app/graphql/resolvers/crm/contacts_resolver.rb new file mode 100644 index 00000000000..9e235669657 --- /dev/null +++ b/app/graphql/resolvers/crm/contacts_resolver.rb @@ -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 diff --git a/app/graphql/resolvers/crm/organizations_resolver.rb b/app/graphql/resolvers/crm/organizations_resolver.rb new file mode 100644 index 00000000000..ef0c930a94a --- /dev/null +++ b/app/graphql/resolvers/crm/organizations_resolver.rb @@ -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 diff --git a/app/graphql/types/customer_relations/contact_state_enum.rb b/app/graphql/types/customer_relations/contact_state_enum.rb new file mode 100644 index 00000000000..445d2a41401 --- /dev/null +++ b/app/graphql/types/customer_relations/contact_state_enum.rb @@ -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 diff --git a/app/graphql/types/customer_relations/organization_state_enum.rb b/app/graphql/types/customer_relations/organization_state_enum.rb new file mode 100644 index 00000000000..ecdd7d092ad --- /dev/null +++ b/app/graphql/types/customer_relations/organization_state_enum.rb @@ -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 diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index a94cd6fad20..49971d52a30 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -201,11 +201,13 @@ module Types field :organizations, Types::CustomerRelations::OrganizationType.connection_type, null: true, - description: "Find organizations of this group." + description: "Find organizations of this group.", + resolver: Resolvers::Crm::OrganizationsResolver field :contacts, Types::CustomerRelations::ContactType.connection_type, null: true, - description: "Find contacts of this group." + description: "Find contacts of this group.", + resolver: Resolvers::Crm::ContactsResolver field :work_item_types, Types::WorkItems::TypeType.connection_type, resolver: Resolvers::WorkItems::TypesResolver, diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 6366ca0dfb1..74318797069 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -65,7 +65,9 @@ module Ci runner_install_help_page: 'https://docs.gitlab.com/runner/install/', registration_token: Gitlab::CurrentSettings.runners_registration_token, online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i, - stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i + stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i, + empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'), + empty_state_filtered_svg_path: image_path('illustrations/magnifying-glass.svg') } end @@ -87,7 +89,9 @@ module Ci group_full_path: group.full_path, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i, - stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i + stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i, + empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'), + empty_state_filtered_svg_path: image_path('illustrations/magnifying-glass.svg') } end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 2dd258b27e6..9aa7602eb96 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -808,6 +808,7 @@ module Ci def execute_hooks return unless project + return if user&.blocked? project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks) project.execute_integrations(build_data.dup, :job_hooks) if project.has_active_integrations?(:job_hooks) diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 67be6b4f8ee..b894c206c8d 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -124,10 +124,10 @@ module Ci # We will start using this column once we complete https://gitlab.com/gitlab-org/gitlab/-/issues/285597 ignore_column :original_filename, remove_with: '14.7', remove_after: '2022-11-22' - mount_file_store_uploader JobArtifactUploader + mount_file_store_uploader JobArtifactUploader, skip_store_file: true - skip_callback :save, :after, :store_file!, if: :store_after_commit? - after_commit :store_file_after_commit!, on: [:create, :update], if: :store_after_commit? + after_save :store_file_in_transaction!, unless: :store_after_commit? + after_commit :store_file_after_transaction!, on: [:create, :update], if: :store_after_commit? validates :file_format, presence: true, unless: :trace?, on: :create validate :validate_file_format!, unless: :trace?, on: :create @@ -362,11 +362,24 @@ module Ci private - def store_file_after_commit! - return unless previous_changes.key?(:file) + def store_file_in_transaction! + store_file_now! if saved_change_to_file? - store_file! - update_file_store + file_stored_in_transaction_hooks + end + + def store_file_after_transaction! + store_file_now! if previous_changes.key?(:file) + + file_stored_after_transaction_hooks + end + + # method overriden in EE + def file_stored_after_transaction_hooks + end + + # method overriden in EE + def file_stored_in_transaction_hooks end def set_size diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a9600148316..451833c1ea2 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -240,7 +240,9 @@ module Ci next if transition.loopback? pipeline.run_after_commit do - PipelineHooksWorker.perform_async(pipeline.id) + unless pipeline.user&.blocked? + PipelineHooksWorker.perform_async(pipeline.id) + end if pipeline.project.jira_subscription_exists? # Passing the seq-id ensures this is idempotent @@ -297,7 +299,12 @@ module Ci ref_status = pipeline.ci_ref&.update_status_by!(pipeline) pipeline.run_after_commit do - PipelineNotificationWorker.perform_async(pipeline.id, ref_status: ref_status) + # We don't send notifications for a pipeline dropped due to the + # user been blocked. + unless pipeline.user&.blocked? + PipelineNotificationWorker + .perform_async(pipeline.id, ref_status: ref_status) + end end end diff --git a/app/models/concerns/file_store_mounter.rb b/app/models/concerns/file_store_mounter.rb index bfcf8a1e7b9..f1ac734635d 100644 --- a/app/models/concerns/file_store_mounter.rb +++ b/app/models/concerns/file_store_mounter.rb @@ -4,9 +4,16 @@ module FileStoreMounter extend ActiveSupport::Concern class_methods do - def mount_file_store_uploader(uploader) + # When `skip_store_file: true` is used, the model MUST explicitly call `store_file_now!` + def mount_file_store_uploader(uploader, skip_store_file: false) mount_uploader(:file, uploader) + if skip_store_file + skip_callback :save, :after, :store_file! + + return + end + # This hook is a no-op when the file is uploaded after_commit after_save :update_file_store, if: :saved_change_to_file? end @@ -16,4 +23,9 @@ module FileStoreMounter # The file.object_store is set during `uploader.store!` and `uploader.migrate!` update_column(:file_store, file.object_store) end + + def store_file_now! + store_file! + update_file_store + end end diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index cdb449e00bf..ded6ab8687a 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class CustomerRelations::Contact < ApplicationRecord + include Gitlab::SQL::Pattern + include Sortable include StripAttribute self.table_name = "customer_relations_contacts" @@ -39,6 +41,25 @@ class CustomerRelations::Contact < ApplicationRecord ']' end + # Searches for contacts with a matching first name, last name, email or description. + # + # This method uses ILIKE on PostgreSQL + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. + def self.search(query) + fuzzy_search(query, [:first_name, :last_name, :email, :description], use_minimum_char_limit: false) + end + + def self.search_by_state(state) + where(state: state) + end + + def self.sort_by_name + order("last_name ASC, first_name ASC") + end + def self.find_ids_by_emails(group, emails) raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb index 32adcc7492b..705e84250c9 100644 --- a/app/models/customer_relations/organization.rb +++ b/app/models/customer_relations/organization.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class CustomerRelations::Organization < ApplicationRecord + include Gitlab::SQL::Pattern + include Sortable include StripAttribute self.table_name = "customer_relations_organizations" @@ -21,6 +23,25 @@ class CustomerRelations::Organization < ApplicationRecord validates :description, length: { maximum: 1024 } validate :validate_root_group + # Searches for organizations with a matching name or description. + # + # This method uses ILIKE on PostgreSQL + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. + def self.search(query) + fuzzy_search(query, [:name, :description], use_minimum_char_limit: false) + end + + def self.search_by_state(state) + where(state: state) + end + + def self.sort_by_name + order(name: :asc) + end + def self.find_by_name(group_id, name) where(group: group_id) .where('LOWER(name) = LOWER(?)', name) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index c6ea364320f..8ded2516b97 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -3,6 +3,7 @@ module Projects class UpdatePagesService < BaseService InvalidStateError = Class.new(StandardError) + WrongUploadedDeploymentSizeError = Class.new(StandardError) BLOCK_SIZE = 32.kilobytes PUBLIC_DIR = 'public' @@ -39,6 +40,9 @@ module Projects end rescue InvalidStateError => e error(e.message) + rescue WrongUploadedDeploymentSizeError => e + error("Uploading artifacts to pages storage failed") + raise e rescue StandardError => e error(e.message) raise e @@ -80,6 +84,10 @@ module Projects ci_build_id: build.id ) + if deployment.size != file.size || deployment.file.size != file.size + raise(WrongUploadedDeploymentSizeError) + end + validate_outdated_sha! project.update_pages_deployment!(deployment) diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 63cf4dfe0ab..cee3d9071b6 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -2,7 +2,7 @@ %section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded), data: { qa_selector: 'service_desk_settings_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk') - %button.btn.gl-button.btn-default.js-settings-toggle + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe %p= _('Enable and disable Service Desk. Some additional configuration might be required. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml index c5cf01820a1..060a854d4e4 100644 --- a/app/views/projects/buttons/_remove_tag.html.haml +++ b/app/views/projects/buttons/_remove_tag.html.haml @@ -8,5 +8,4 @@ - title = s_('TagsPage|Only a project maintainer or owner can delete a protected tag') - disabled = true -%button{ type: "button", class: "js-delete-tag-button gl-button btn btn-default btn-icon has-tooltip gl-ml-3\! #{disabled ? 'disabled' : ''}", title: title, disabled: disabled, data: { path: project_tag_path(@project, tag.name), tag_name: tag.name, is_protected: protected_tag?(project, tag).to_s } } - = sprite_icon('remove', css_class: 'gl-icon') += render Pajamas::ButtonComponent.new(variant: :default, icon: 'remove', button_options: { class: "js-delete-tag-button gl-ml-3\!", 'aria-label': s_('TagsPage|Delete tag'), title: title, disabled: disabled, data: { toggle: 'tooltip', container: 'body', path: project_tag_path(@project, tag.name), tag_name: tag.name, is_protected: protected_tag?(project, tag).to_s } }) diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index eb5d0086592..800cf50e732 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -13,6 +13,7 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker def perform(pipeline_id) pipeline = Ci::Pipeline.find_by_id(pipeline_id) return unless pipeline + return if pipeline.user&.blocked? Ci::Pipelines::HookService.new(pipeline).execute end diff --git a/app/workers/pipeline_notification_worker.rb b/app/workers/pipeline_notification_worker.rb index 640f3494d58..2ed2e2ff1d0 100644 --- a/app/workers/pipeline_notification_worker.rb +++ b/app/workers/pipeline_notification_worker.rb @@ -23,6 +23,7 @@ class PipelineNotificationWorker # rubocop:disable Scalability/IdempotentWorker pipeline = Ci::Pipeline.find_by_id(pipeline_id) return unless pipeline + return if pipeline.user&.blocked? NotificationService.new.pipeline_finished(pipeline, ref_status: ref_status, recipients: recipients) end diff --git a/config/feature_flags/development/ci_enforce_throttle_pipelines_creation_override.yml b/config/feature_flags/development/ci_enforce_throttle_pipelines_creation_override.yml new file mode 100644 index 00000000000..e6458729cc9 --- /dev/null +++ b/config/feature_flags/development/ci_enforce_throttle_pipelines_creation_override.yml @@ -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 diff --git a/db/post_migrate/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects.rb b/db/post_migrate/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects.rb new file mode 100644 index 00000000000..47b1c169d74 --- /dev/null +++ b/db/post_migrate/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects.rb @@ -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 diff --git a/db/schema_migrations/20220520040416 b/db/schema_migrations/20220520040416 new file mode 100644 index 00000000000..2444438b2e4 --- /dev/null +++ b/db/schema_migrations/20220520040416 @@ -0,0 +1 @@ +5055a0f5fd7125d353654be2425c881afa42a3b09eb0ab34dd0929b3440aa643
\ No newline at end of file diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 8b9e52945df..f46b0f4d2dc 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -11519,7 +11519,6 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="groupallowstalerunnerpruning"></a>`allowStaleRunnerPruning` | [`Boolean!`](#boolean) | Indicates whether to regularly prune stale group runners. Defaults to false. | | <a id="groupautodevopsenabled"></a>`autoDevopsEnabled` | [`Boolean`](#boolean) | Indicates whether Auto DevOps is enabled for all projects within this group. | | <a id="groupavatarurl"></a>`avatarUrl` | [`String`](#string) | Avatar URL of the group. | -| <a id="groupcontacts"></a>`contacts` | [`CustomerRelationsContactConnection`](#customerrelationscontactconnection) | Find contacts of this group. (see [Connections](#connections)) | | <a id="groupcontainerrepositoriescount"></a>`containerRepositoriesCount` | [`Int!`](#int) | Number of container repositories in the group. | | <a id="groupcontainslockedprojects"></a>`containsLockedProjects` | [`Boolean!`](#boolean) | Includes at least one project where the repository size exceeds the limit. | | <a id="groupcrossprojectpipelineavailable"></a>`crossProjectPipelineAvailable` | [`Boolean!`](#boolean) | Indicates if the cross_project_pipeline feature is available for the namespace. | @@ -11546,7 +11545,6 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="grouplfsenabled"></a>`lfsEnabled` | [`Boolean`](#boolean) | Indicates if Large File Storage (LFS) is enabled for namespace. | | <a id="groupmentionsdisabled"></a>`mentionsDisabled` | [`Boolean`](#boolean) | Indicates if a group is disabled from getting mentioned. | | <a id="groupname"></a>`name` | [`String!`](#string) | Name of the namespace. | -| <a id="grouporganizations"></a>`organizations` | [`CustomerRelationsOrganizationConnection`](#customerrelationsorganizationconnection) | Find organizations of this group. (see [Connections](#connections)) | | <a id="grouppackagesettings"></a>`packageSettings` | [`PackageSettings`](#packagesettings) | Package settings for the namespace. | | <a id="groupparent"></a>`parent` | [`Group`](#group) | Parent group. | | <a id="grouppath"></a>`path` | [`String!`](#string) | Path of the namespace. | @@ -11644,6 +11642,23 @@ four standard [pagination arguments](#connection-pagination-arguments): | ---- | ---- | ----------- | | <a id="groupcomplianceframeworksid"></a>`id` | [`ComplianceManagementFrameworkID`](#compliancemanagementframeworkid) | Global ID of a specific compliance framework to return. | +##### `Group.contacts` + +Find contacts of this group. + +Returns [`CustomerRelationsContactConnection`](#customerrelationscontactconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="groupcontactssearch"></a>`search` | [`String`](#string) | Search term to find contacts with. | +| <a id="groupcontactsstate"></a>`state` | [`CustomerRelationsContactState`](#customerrelationscontactstate) | State of the contacts to search for. | + ##### `Group.containerRepositories` Container repositories of the group. @@ -11983,6 +11998,23 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="groupmilestonestimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | | <a id="groupmilestonestitle"></a>`title` | [`String`](#string) | Title of the milestone. | +##### `Group.organizations` + +Find organizations of this group. + +Returns [`CustomerRelationsOrganizationConnection`](#customerrelationsorganizationconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="grouporganizationssearch"></a>`search` | [`String`](#string) | Search term used to find organizations with. | +| <a id="grouporganizationsstate"></a>`state` | [`CustomerRelationsOrganizationState`](#customerrelationsorganizationstate) | State of the organization to search for. | + ##### `Group.packages` Packages of the group. @@ -18492,6 +18524,20 @@ Values for sorting tags. | <a id="containerrepositorytagsortname_asc"></a>`NAME_ASC` | Ordered by name in ascending order. | | <a id="containerrepositorytagsortname_desc"></a>`NAME_DESC` | Ordered by name in descending order. | +### `CustomerRelationsContactState` + +| Value | Description | +| ----- | ----------- | +| <a id="customerrelationscontactstateactive"></a>`active` | Active contact. | +| <a id="customerrelationscontactstateinactive"></a>`inactive` | Inactive contact. | + +### `CustomerRelationsOrganizationState` + +| Value | Description | +| ----- | ----------- | +| <a id="customerrelationsorganizationstateactive"></a>`active` | Active organization. | +| <a id="customerrelationsorganizationstateinactive"></a>`inactive` | Inactive organization. | + ### `DastProfileCadenceUnit` Unit for the duration of Dast Profile Cadence. diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index e71bd8e57f0..b07e3ca328b 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -230,7 +230,7 @@ include: file: '/templates/.gitlab-ci-template.yml' - project: 'my-group/my-project' - ref: v1.0.0 + ref: v1.0.0 # Git Tag file: '/templates/.gitlab-ci-template.yml' - project: 'my-group/my-project' diff --git a/doc/ci/yaml/yaml_optimization.md b/doc/ci/yaml/yaml_optimization.md index 503ba9bbd80..e5d9e011230 100644 --- a/doc/ci/yaml/yaml_optimization.md +++ b/doc/ci/yaml/yaml_optimization.md @@ -450,5 +450,26 @@ test-vars-2: - printenv ``` -You can't reuse a section that already includes a `!reference` tag. Only one level -of nesting is supported. +### Nest `!reference` tags in `script`, `before_script`, and `after_script` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74792) in GitLab 14.8. + +You can nest `!reference` tags up to 10 levels deep in `script`, `before_script`, and `after_script` sections. Use nested tags to define reusable sections when building more complex scripts. For example: + +```yaml +.snippets: + one: + - echo "ONE!" + two: + - !reference [.snippets, one] + - echo "TWO!" + three: + - !reference [.snippets, two] + - echo "THREE!" + +nested-references: + script: + - !reference [.snippets, three] +``` + +In this example, the `nested-references` job runs all three `echo` commands. diff --git a/doc/development/fe_guide/emojis.md b/doc/development/fe_guide/emojis.md index 7ef88c5ca19..3c7fc20440b 100644 --- a/doc/development/fe_guide/emojis.md +++ b/doc/development/fe_guide/emojis.md @@ -24,6 +24,10 @@ when your platform does not support it. 1. Ensure new sprite sheets generated for 1x and 2x - `app/assets/images/emoji.png` - `app/assets/images/emoji@2x.png` + 1. Update `fixtures/emojis/intents.json` with any new emoji that we would like to highlight as having positive or negative intent. + - Positive intent should be set to `0.5`. + - Neutral intent can be set to `1`. This is applied to all emoji automatically so there is no need to set this explicitly. + - Negative intent should be set to `1.5`. 1. Ensure you see new individual images copied into `app/assets/images/emoji/` 1. Ensure you can see the new emojis and their aliases in the GitLab Flavored Markdown (GLFM) Autocomplete 1. Ensure you can see the new emojis and their aliases in the award emoji menu diff --git a/doc/development/gitlab_flavored_markdown/specification_guide/index.md b/doc/development/gitlab_flavored_markdown/specification_guide/index.md index d79a2a5d487..cedf44cf1fc 100644 --- a/doc/development/gitlab_flavored_markdown/specification_guide/index.md +++ b/doc/development/gitlab_flavored_markdown/specification_guide/index.md @@ -52,7 +52,10 @@ this inconsistency. Some places in the code refer to both the GitLab and GitHub specifications simultaneous in the same areas of logic. In these situations, _GitHub_ Flavored Markdown may be referred to with variable or constant names like -`ghfm_` to avoid confusion. +`ghfm_` to avoid confusion. For example, we use the `ghfm` acronym for the +[`ghfm_spec_v_0.29.txt` GitHub Flavored Markdown specification file](#github-flavored-markdown-specification) +which is committed to the `gitlab` repo and used as input to the +[`update_specification.rb` script](#update-specificationrb-script). The original CommonMark specification is referred to as _CommonMark_ (no acronym). @@ -434,7 +437,7 @@ subgraph script: A --> B{Backend Markdown API} end subgraph input:<br/>input specification files - C[gfm_spec_v_0.29.txt] --> A + C[ghfm_spec_v_0.29.txt] --> A D[glfm_intro.txt] --> A E[glfm_canonical_examples.txt] --> A end @@ -572,12 +575,16 @@ updated, as in the case of all GFM files. ##### GitHub Flavored Markdown specification -[`glfm_specification/input/github_flavored_markdown/gfm_spec_v_0.29.txt`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/glfm_specification/input/github_flavored_markdown/gfm_spec_v_0.29.txt) +[`glfm_specification/input/github_flavored_markdown/ghfm_spec_v_0.29.txt`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/glfm_specification/input/github_flavored_markdown/ghfm_spec_v_0.29.txt) is the official latest [GFM `spec.txt`](https://github.com/github/cmark-gfm/blob/master/test/spec.txt). - It is automatically downloaded and updated by `update-specification.rb` script. - When it is downloaded, the version number is added to the filename. +NOTE: +This file uses the `ghfm` acronym instead of `gfm`, as +explained in the [Acronyms section](#acronyms-glfm-ghfm-gfm-commonmark). + ##### `glfm_intro.txt` [`glfm_specification/input/gitlab_flavored_markdown/glfm_intro.txt`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/glfm_specification/input/gitlab_flavored_markdown/glfm_intro.txt) @@ -900,12 +907,12 @@ Any exceptions or failures which occur when generating HTML are replaced with an ```yaml 06_04_inlines_emphasis_and_strong_emphasis_1: - canonical: | - <p><em>foo bar</em></p> - static: | - <p data-sourcepos="1:1-1:9" dir="auto"><strong>foo bar</strong></p> - wysiwyg: | - <p><strong>foo bar</strong></p> + canonical: | + <p><em>foo bar</em></p> + static: | + <p data-sourcepos="1:1-1:9" dir="auto"><strong>foo bar</strong></p> + wysiwyg: | + <p><strong>foo bar</strong></p> ``` NOTE: diff --git a/doc/operations/incident_management/alerts.md b/doc/operations/incident_management/alerts.md index 008e41f5d64..af42571f82f 100644 --- a/doc/operations/incident_management/alerts.md +++ b/doc/operations/incident_management/alerts.md @@ -205,7 +205,7 @@ To assign an alert: ![Alert Details View Assignees](img/alert_details_assignees_v13_1.png) 1. If the right sidebar is not expanded, select - **Expand sidebar** (**{angle-double-right}**) to expand it. + **Expand sidebar** (**{chevron-double-lg-right}**) to expand it. 1. On the right sidebar, locate the **Assignee**, and then select **Edit**. From the list, select each user you want to assign to the alert. diff --git a/doc/user/project/integrations/pipeline_status_emails.md b/doc/user/project/integrations/pipeline_status_emails.md index 742ab977090..c58f5a13613 100644 --- a/doc/user/project/integrations/pipeline_status_emails.md +++ b/doc/user/project/integrations/pipeline_status_emails.md @@ -21,3 +21,6 @@ To enable pipeline status emails: **Notify only broken pipelines**. 1. Select the branches to send notifications for. 1. Select **Save changes**. + +In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546) +and later, pipeline notifications triggered by blocked users are not delivered. diff --git a/doc/user/project/integrations/webhook_events.md b/doc/user/project/integrations/webhook_events.md index 465a51def7c..1d0cd3cdbbd 100644 --- a/doc/user/project/integrations/webhook_events.md +++ b/doc/user/project/integrations/webhook_events.md @@ -1050,6 +1050,9 @@ Pipeline events are triggered when the status of a pipeline changes. In [GitLab 13.9](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53159) and later, the pipeline webhook returns only the latest jobs. +In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546) +and later, pipeline webhooks triggered by blocked users are not processed. + Request header: ```plaintext @@ -1310,6 +1313,9 @@ Job events are triggered when the status of a job changes. The `commit.id` in the payload is the ID of the pipeline, not the ID of the commit. +In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546) +and later, job events triggered by blocked users are not processed. + Request header: ```plaintext diff --git a/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png b/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png Binary files differdeleted file mode 100644 index 17ce42e7a69..00000000000 --- a/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png +++ /dev/null diff --git a/doc/user/project/merge_requests/methods/index.md b/doc/user/project/merge_requests/methods/index.md index 12e49e43fae..d8b4644b1b8 100644 --- a/doc/user/project/merge_requests/methods/index.md +++ b/doc/user/project/merge_requests/methods/index.md @@ -87,8 +87,6 @@ method selected, you can accept it **only if a fast-forward merge is possible**. ## Rebasing in (semi-)linear merge methods -> Rebasing without running a CI/CD pipeline [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) in GitLab 14.7. - In these merge methods, you can merge only when your source branch is up-to-date with the target branch: - Merge commit with semi-linear history. @@ -96,11 +94,7 @@ In these merge methods, you can merge only when your source branch is up-to-date If a fast-forward merge is not possible but a conflict-free rebase is possible, GitLab offers you the [`/rebase` quick action](../../../../topics/git/git_rebase.md#rebase-from-the-gitlab-ui), -and the ability to **Rebase** from the user interface: - -![Fast forward merge request](../img/ff_merge_rebase_v14_9.png) - -In [GitLab 14.7](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) and later, you can also rebase without running a CI/CD pipeline. +and the ability to select **Rebase** from the user interface. If the target branch is ahead of the source branch and a conflict-free rebase is not possible, you must rebase the source branch locally before you can do a fast-forward merge. @@ -110,6 +104,23 @@ not possible, you must rebase the source branch locally before you can do a fast Rebasing may be required before squashing, even though squashing can itself be considered equivalent to rebasing. +### Rebase without CI/CD pipeline + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) in GitLab 14.7 [with a flag](../../../../administration/feature_flags.md) named `rebase_without_ci_ui`. Disabled by default. + +FLAG: +On GitLab.com and self-managed GitLab, by default this feature is not available. To make it available, +ask an administrator to [enable the feature flag](../../../../administration/feature_flags.md) named `rebase_without_ci_ui`. +The feature is not ready for production use. + +To rebase a merge request's branch without triggering a CI/CD pipeline, select +**Rebase without pipeline** from the merge request reports section. +This option is available when fast-forward merge is not possible but a conflict-free +rebase is possible. + +Rebasing without a CI/CD pipeline saves resources in projects with a semi-linear +workflow that requires frequent rebases. + ## Related topics - [Commits history](../commits.md) diff --git a/fixtures/emojis/intents.json b/fixtures/emojis/intents.json new file mode 100644 index 00000000000..8e8b02aaddb --- /dev/null +++ b/fixtures/emojis/intents.json @@ -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 +} diff --git a/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects.rb b/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects.rb new file mode 100644 index 00000000000..e85b1bc402a --- /dev/null +++ b/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects.rb @@ -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 diff --git a/lib/gitlab/ci/pipeline/chain/limit/rate_limit.rb b/lib/gitlab/ci/pipeline/chain/limit/rate_limit.rb index c6d6c5dc14d..af5cc7fe523 100644 --- a/lib/gitlab/ci/pipeline/chain/limit/rate_limit.rb +++ b/lib/gitlab/ci/pipeline/chain/limit/rate_limit.rb @@ -7,6 +7,7 @@ module Gitlab module Limit class RateLimit < Chain::Base include Chain::Helpers + include ::Gitlab::Utils::StrongMemoize def perform! # We exclude child-pipelines from the rate limit because they represent @@ -41,7 +42,9 @@ module Gitlab commit_sha: command.sha, current_user_id: current_user.id, subscription_plan: project.actual_plan_name, - message: 'Activated pipeline creation rate limit' + message: 'Activated pipeline creation rate limit', + throttled: enforce_throttle?, + throttle_override: throttle_override? ) end @@ -50,9 +53,16 @@ module Gitlab end def enforce_throttle? - ::Feature.enabled?( - :ci_enforce_throttle_pipelines_creation, - project) + strong_memoize(:enforce_throttle) do + ::Feature.enabled?(:ci_enforce_throttle_pipelines_creation, project) && + !throttle_override? + end + end + + def throttle_override? + strong_memoize(:throttle_override) do + ::Feature.enabled?(:ci_enforce_throttle_pipelines_creation_override, project) + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fa56971d6f6..d7cd925aff6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -17232,7 +17232,7 @@ msgstr "" msgid "GitLab is open source software to collaborate on code." msgstr "" -msgid "GitLab is undergoing maintenance and is operating in read-only mode." +msgid "GitLab is undergoing maintenance" msgstr "" msgid "GitLab logo" @@ -19088,6 +19088,9 @@ msgstr "" msgid "IDE|Review" msgstr "" +msgid "IDE|Start a new merge request" +msgstr "" + msgid "IDE|Successful commit" msgstr "" @@ -25758,9 +25761,6 @@ msgstr "" msgid "No runner executable" msgstr "" -msgid "No runners found" -msgstr "" - msgid "No schedules" msgstr "" @@ -32765,6 +32765,9 @@ msgstr[1] "" msgid "Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet." msgstr "" +msgid "Runners|A new version is available" +msgstr "" + msgid "Runners|A periodic background task deletes runners that haven't contacted GitLab in more than %{elapsedTime}. %{linkStart}Can I view how many runners were deleted?%{linkEnd}" msgstr "" @@ -32866,6 +32869,9 @@ msgstr "" msgid "Runners|Download latest binary" msgstr "" +msgid "Runners|Edit your search and try again" +msgstr "" + msgid "Runners|Enable stale runner cleanup" msgstr "" @@ -32878,6 +32884,9 @@ msgstr "" msgid "Runners|Executor" msgstr "" +msgid "Runners|Get started with runners" +msgstr "" + msgid "Runners|Group" msgstr "" @@ -32926,6 +32935,9 @@ msgstr "" msgid "Runners|New registration token generated!" msgstr "" +msgid "Runners|No results found" +msgstr "" + msgid "Runners|No spot. Default choice for Windows Shell executor." msgstr "" @@ -33069,6 +33081,9 @@ msgstr "" msgid "Runners|Runners" msgstr "" +msgid "Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner." +msgstr "" + msgid "Runners|Runs untagged jobs" msgstr "" @@ -33134,6 +33149,9 @@ msgstr "" msgid "Runners|This runner is available to all projects and subgroups in a group." msgstr "" +msgid "Runners|This runner is outdated, an upgrade is recommended" +msgstr "" + msgid "Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation." msgstr "" @@ -33203,6 +33221,12 @@ msgstr "" msgid "Runners|stale" msgstr "" +msgid "Runners|upgrade available" +msgstr "" + +msgid "Runners|upgrade recommended" +msgstr "" + msgid "Running" msgstr "" @@ -36268,9 +36292,6 @@ msgstr "" msgid "Start a new discussion…" msgstr "" -msgid "Start a new merge request" -msgstr "" - msgid "Start a new merge request with these changes" msgstr "" @@ -38748,9 +38769,6 @@ msgstr "" msgid "This GitLab instance is licensed at the %{insufficient_license} tier. Geo is only available for users who have at least a Premium license." msgstr "" -msgid "This GitLab instance is undergoing maintenance and is operating in read-only mode." -msgstr "" - msgid "This PDF is too large to display. Please download to view." msgstr "" diff --git a/package.json b/package.json index 7abec7eacf7..1ba5944e93d 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", - "@gitlab/svgs": "2.17.0", + "@gitlab/svgs": "2.18.0", "@gitlab/ui": "40.7.1", "@gitlab/visual-review-tools": "1.7.3", "@rails/actioncable": "6.1.4-7", diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index e1a1e2bbb2d..d312965f6cf 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -115,13 +115,17 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content("runner-bar") end - it 'shows no runner when description does not match' do - input_filtered_search_keys('runner-baz') + context 'when description does not match' do + before do + input_filtered_search_keys('runner-baz') + end - expect(page).to have_link('All 0') - expect(page).to have_link('Instance 0') + it_behaves_like 'shows no runners found' - expect(page).to have_text 'No runners found' + it 'shows no runner' do + expect(page).to have_link('All 0') + expect(page).to have_link('Instance 0') + end end end @@ -190,14 +194,6 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-never-contacted' end - it 'shows no runner when status does not match' do - input_filtered_search_filter_is_only('Status', 'Stale') - - expect(page).to have_link('All 0') - - expect(page).to have_text 'No runners found' - end - it 'shows correct runner when status is selected and search term is entered' do input_filtered_search_filter_is_only('Status', 'Online') input_filtered_search_keys('runner-1') @@ -225,6 +221,18 @@ RSpec.describe "Admin Runners" do expect(page).to have_selector '.badge', text: 'never contacted' end end + + context 'when status does not match' do + before do + input_filtered_search_filter_is_only('Status', 'Stale') + end + + it_behaves_like 'shows no runners found' + + it 'shows no runner' do + expect(page).to have_link('All 0') + end + end end describe 'filter by type' do @@ -273,21 +281,6 @@ RSpec.describe "Admin Runners" do end end - it 'shows no runner when type does not match' do - visit admin_runners_path - - page.within('[data-testid="runner-type-tabs"]') do - click_on 'Instance' - - expect(page).to have_link('Instance', class: 'active') - end - - expect(page).not_to have_content 'runner-project' - expect(page).not_to have_content 'runner-group' - - expect(page).to have_text 'No runners found' - end - it 'shows correct runner when type is selected and search term is entered' do create(:ci_runner, :project, description: 'runner-2-project', projects: [project]) @@ -327,6 +320,24 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-group' expect(page).not_to have_content 'runner-paused-project' end + + context 'when type does not match' do + before do + visit admin_runners_path + page.within('[data-testid="runner-type-tabs"]') do + click_on 'Instance' + + expect(page).to have_link('Instance', class: 'active') + end + end + + it_behaves_like 'shows no runners found' + + it 'shows no runner' do + expect(page).not_to have_content 'runner-project' + expect(page).not_to have_content 'runner-group' + end + end end describe 'filter by tag' do @@ -358,15 +369,6 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-red' end - it 'shows no runner when tag does not match' do - visit admin_runners_path - - input_filtered_search_filter_is_only('Tags', 'green') - - expect(page).not_to have_content 'runner-blue' - expect(page).to have_text 'No runners found' - end - it 'shows correct runner when tag is selected and search term is entered' do create(:ci_runner, :instance, description: 'runner-2-blue', tag_list: ['blue']) @@ -384,6 +386,19 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-blue' expect(page).not_to have_content 'runner-red' end + + context 'when tag does not match' do + before do + visit admin_runners_path + input_filtered_search_filter_is_only('Tags', 'green') + end + + it_behaves_like 'shows no runners found' + + it 'shows no runner' do + expect(page).not_to have_content 'runner-blue' + end + end end it 'sorts by last contact date' do @@ -419,7 +434,7 @@ RSpec.describe "Admin Runners" do visit admin_runners_path end - it_behaves_like "shows no runners" + it_behaves_like 'shows no runners registered' it 'shows tabs with total counts equal to 0' do expect(page).to have_link('All 0') diff --git a/spec/features/groups/group_runners_spec.rb b/spec/features/groups/group_runners_spec.rb index 1d821edefa3..ec088f60b80 100644 --- a/spec/features/groups/group_runners_spec.rb +++ b/spec/features/groups/group_runners_spec.rb @@ -33,7 +33,7 @@ RSpec.describe "Group Runners" do visit group_runners_path(group) end - it_behaves_like "shows no runners" + it_behaves_like 'shows no runners registered' it 'shows tabs with total counts equal to 0' do expect(page).to have_link('All 0') @@ -70,6 +70,18 @@ RSpec.describe "Group Runners" do expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner)) end end + + context 'when description does not match' do + before do + input_filtered_search_keys('runner-baz') + end + + it_behaves_like 'shows no runners found' + + it 'shows no runner' do + expect(page).not_to have_content 'runner-foo' + end + end end context "with an online project runner" do diff --git a/spec/finders/crm/contacts_finder_spec.rb b/spec/finders/crm/contacts_finder_spec.rb index 151af1ad825..14f838812a6 100644 --- a/spec/finders/crm/contacts_finder_spec.rb +++ b/spec/finders/crm/contacts_finder_spec.rb @@ -66,5 +66,83 @@ RSpec.describe Crm::ContactsFinder do expect(subject).to be_empty end end + + context 'with search informations' do + let_it_be(:search_test_group) { create(:group, :crm_enabled) } + + let_it_be(:search_test_a) do + create( + :contact, + group: search_test_group, + first_name: "ABC", + last_name: "DEF", + email: "ghi@test.com", + description: "LMNO", + state: "inactive" + ) + end + + let_it_be(:search_test_b) do + create( + :contact, + group: search_test_group, + first_name: "PQR", + last_name: "STU", + email: "vwx@test.com", + description: "YZ", + state: "active" + ) + end + + before do + search_test_group.add_developer(user) + end + + context 'when search term is empty' do + it 'returns all group contacts alphabetically ordered' do + finder = described_class.new(user, group: search_test_group, search: "") + expect(finder.execute).to eq([search_test_a, search_test_b]) + end + end + + context 'when search term is not empty' do + it 'searches for first name ignoring casing' do + finder = described_class.new(user, group: search_test_group, search: "aBc") + expect(finder.execute).to match_array([search_test_a]) + end + + it 'searches for last name ignoring casing' do + finder = described_class.new(user, group: search_test_group, search: "StU") + expect(finder.execute).to match_array([search_test_b]) + end + + it 'searches for email' do + finder = described_class.new(user, group: search_test_group, search: "ghi") + expect(finder.execute).to match_array([search_test_a]) + end + + it 'searches for description ignoring casing' do + finder = described_class.new(user, group: search_test_group, search: "Yz") + expect(finder.execute).to match_array([search_test_b]) + end + + it 'fuzzy searches for email and last name' do + finder = described_class.new(user, group: search_test_group, search: "s") + expect(finder.execute).to match_array([search_test_a, search_test_b]) + end + end + + context 'when searching for contacts state' do + it 'returns only inactive contacts' do + finder = described_class.new(user, group: search_test_group, state: :inactive) + expect(finder.execute).to match_array([search_test_a]) + end + + it 'returns only active contacts' do + finder = described_class.new(user, group: search_test_group, state: :active) + expect(finder.execute).to match_array([search_test_b]) + end + end + end end end diff --git a/spec/finders/crm/organizations_finder_spec.rb b/spec/finders/crm/organizations_finder_spec.rb new file mode 100644 index 00000000000..94b5d9e5874 --- /dev/null +++ b/spec/finders/crm/organizations_finder_spec.rb @@ -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 diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js index 014a7854024..6c9291bdc8f 100644 --- a/spec/frontend/__helpers__/emoji.js +++ b/spec/frontend/__helpers__/emoji.js @@ -58,6 +58,16 @@ export const validEmoji = { unicodeVersion: '6.0', description: 'because it contains multiple zero width joiners', }, + thumbsup: { + moji: '👍', + unicodeVersion: '6.0', + description: 'thumbs up sign', + }, + thumbsdown: { + moji: '👎', + description: 'thumbs down sign', + unicodeVersion: '6.0', + }, }; export const invalidEmoji = { diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 5d657745615..b14bc5122b9 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -57,6 +57,18 @@ describe('AwardsHandler', () => { d: 'white question mark ornament', u: '6.0', }, + thumbsup: { + c: 'people', + e: '👍', + d: 'thumbs up sign', + u: '6.0', + }, + thumbsdown: { + c: 'people', + e: '👎', + d: 'thumbs down sign', + u: '6.0', + }, }; const openAndWaitForEmojiMenu = (sel = '.js-add-award') => { @@ -296,6 +308,23 @@ describe('AwardsHandler', () => { awardsHandler.searchEmojis('👼'); expect($('[data-name=angel]').is(':visible')).toBe(true); }); + + it('should show positive intent emoji first', async () => { + await openAndWaitForEmojiMenu(); + + awardsHandler.searchEmojis('thumb'); + + const $menu = $('.emoji-menu'); + const $thumbsUpItem = $menu.find('[data-name=thumbsup]'); + const $thumbsDownItem = $menu.find('[data-name=thumbsdown]'); + + expect($thumbsUpItem.is(':visible')).toBe(true); + expect($thumbsDownItem.is(':visible')).toBe(true); + + expect($thumbsUpItem.parents('.emoji-menu-list-item').index()).toBeLessThan( + $thumbsDownItem.parents('.emoji-menu-list-item').index(), + ); + }); }); describe('emoji menu', () => { diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index cc037586496..dc8f50e0e4b 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -24,6 +24,7 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; +import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants'; const emptySupportMap = { personZwj: false, @@ -436,14 +437,28 @@ describe('emoji', () => { it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => { const search = searchEmoji(input); - const expected = Object.keys(validEmoji).map((name) => { - return { - emoji: mockEmojiData[name], - field: 'd', - fieldValue: mockEmojiData[name].d, - score: 0, - }; - }); + const expected = Object.keys(validEmoji) + .map((name) => { + let score = NEUTRAL_INTENT_MULTIPLIER; + + // Positive intent value retrieved from ~/emoji/intents.json + if (name === 'thumbsup') { + score = 0.5; + } + + // Negative intent value retrieved from ~/emoji/intents.json + if (name === 'thumbsdown') { + score = 1.5; + } + + return { + emoji: mockEmojiData[name], + field: 'd', + fieldValue: mockEmojiData[name].d, + score, + }; + }) + .sort(sortEmoji); expect(search).toEqual(expected); }); @@ -457,7 +472,7 @@ describe('emoji', () => { name: 'atom', field: 'e', fieldValue: 'atom', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -469,7 +484,7 @@ describe('emoji', () => { name: 'atom', field: 'alias', fieldValue: 'atom_symbol', - score: 4, + score: 16, }, ], ], @@ -481,7 +496,7 @@ describe('emoji', () => { name: 'atom', field: 'alias', fieldValue: 'atom_symbol', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -509,7 +524,7 @@ describe('emoji', () => { { name: 'atom', field: 'd', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -521,7 +536,7 @@ describe('emoji', () => { { name: 'atom', field: 'd', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -533,7 +548,7 @@ describe('emoji', () => { { name: 'grey_question', field: 'name', - score: 5, + score: 32, }, ], ], @@ -544,7 +559,7 @@ describe('emoji', () => { { name: 'grey_question', field: 'd', - score: 24, + score: 16777216, }, ], ], @@ -553,14 +568,14 @@ describe('emoji', () => { 'heart', [ { - name: 'black_heart', - field: 'd', - score: 6, - }, - { name: 'heart', field: 'name', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { + name: 'black_heart', + field: 'd', + score: 64, }, ], ], @@ -569,14 +584,14 @@ describe('emoji', () => { 'HEART', [ { - name: 'black_heart', - field: 'd', - score: 6, - }, - { name: 'heart', field: 'name', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { + name: 'black_heart', + field: 'd', + score: 64, }, ], ], @@ -585,14 +600,30 @@ describe('emoji', () => { 'star', [ { + name: 'star', + field: 'name', + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { name: 'custard', field: 'd', - score: 2, + score: 4, + }, + ], + ], + [ + 'searching for emoji with intentions assigned', + 'thumbs', + [ + { + name: 'thumbsup', + field: 'd', + score: 0.5, }, { - name: 'star', - field: 'name', - score: 0, + name: 'thumbsdown', + field: 'd', + score: 1.5, }, ], ], @@ -619,10 +650,10 @@ describe('emoji', () => { [ { score: 10, fieldValue: '', emoji: { name: 'a' } }, { score: 5, fieldValue: '', emoji: { name: 'b' } }, - { score: 0, fieldValue: '', emoji: { name: 'c' } }, + { score: 1, fieldValue: '', emoji: { name: 'c' } }, ], [ - { score: 0, fieldValue: '', emoji: { name: 'c' } }, + { score: 1, fieldValue: '', emoji: { name: 'c' } }, { score: 5, fieldValue: '', emoji: { name: 'b' } }, { score: 10, fieldValue: '', emoji: { name: 'a' } }, ], @@ -630,25 +661,25 @@ describe('emoji', () => { [ 'should correctly sort by fieldValue', [ - { score: 0, fieldValue: 'y', emoji: { name: 'b' } }, - { score: 0, fieldValue: 'x', emoji: { name: 'a' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'c' } }, + { score: 1, fieldValue: 'y', emoji: { name: 'b' } }, + { score: 1, fieldValue: 'x', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'c' } }, ], [ - { score: 0, fieldValue: 'x', emoji: { name: 'a' } }, - { score: 0, fieldValue: 'y', emoji: { name: 'b' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'c' } }, + { score: 1, fieldValue: 'x', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'y', emoji: { name: 'b' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'c' } }, ], ], [ 'should correctly sort by score and then by fieldValue (in order)', [ { score: 5, fieldValue: 'y', emoji: { name: 'c' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'a' } }, { score: 5, fieldValue: 'x', emoji: { name: 'b' } }, ], [ - { score: 0, fieldValue: 'z', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'a' } }, { score: 5, fieldValue: 'x', emoji: { name: 'b' } }, { score: 5, fieldValue: 'y', emoji: { name: 'c' } }, ], @@ -656,7 +687,7 @@ describe('emoji', () => { ]; it.each(testCases)('%s', (_, scoredItems, expected) => { - expect(sortEmoji(scoredItems)).toEqual(expected); + expect(scoredItems.sort(sortEmoji)).toEqual(expected); }); }); }); diff --git a/spec/frontend/emoji/utils_spec.js b/spec/frontend/emoji/utils_spec.js new file mode 100644 index 00000000000..397388ca0ae --- /dev/null +++ b/spec/frontend/emoji/utils_spec.js @@ -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); + }); + }); +}); diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index e17e73a93c4..a79982fa647 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -26,6 +26,12 @@ RSpec.describe 'Runner (JavaScript fixtures)' do remove_repository(project) end + before do + allow(Gitlab::Ci::RunnerUpgradeCheck.instance) + .to receive(:check_runner_upgrade_status) + .and_return(:not_available) + end + describe do before do sign_in(admin) diff --git a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js index 64b53264b4d..2a455c9d7c1 100644 --- a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js @@ -1,193 +1,97 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { projectData, branches } from 'jest/ide/mock_data'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { GlFormCheckbox } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NewMergeRequestOption from '~/ide/components/commit_sidebar/new_merge_request_option.vue'; -import { PERMISSION_CREATE_MR } from '~/ide/constants'; import { createStore } from '~/ide/stores'; -import { - COMMIT_TO_CURRENT_BRANCH, - COMMIT_TO_NEW_BRANCH, -} from '~/ide/stores/modules/commit/constants'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -describe('create new MR checkbox', () => { - let store; - let vm; - - const setMR = () => { - vm.$store.state.currentMergeRequestId = '1'; - vm.$store.state.projects[store.state.currentProjectId].mergeRequests[ - store.state.currentMergeRequestId - ] = { foo: 'bar' }; - }; - - const setPermissions = (permissions) => { - store.state.projects[store.state.currentProjectId].userPermissions = permissions; - }; - - const createComponent = ({ currentBranchId = 'main', createNewBranch = false } = {}) => { - const Component = Vue.extend(NewMergeRequestOption); - - vm = createComponentWithStore(Component, store); - - vm.$store.state.commit.commitAction = createNewBranch - ? COMMIT_TO_NEW_BRANCH - : COMMIT_TO_CURRENT_BRANCH; +Vue.use(Vuex); - vm.$store.state.currentBranchId = currentBranchId; - - store.state.projects.abcproject.branches[currentBranchId] = branches.find( - (branch) => branch.name === currentBranchId, - ); - - return vm.$mount(); - }; +describe('NewMergeRequestOption component', () => { + let store; + let wrapper; - const findInput = () => vm.$el.querySelector('input[type="checkbox"]'); - const findLabel = () => vm.$el.querySelector('.js-ide-commit-new-mr'); + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findFieldset = () => wrapper.findByTestId('new-merge-request-fieldset'); + const findTooltip = () => getBinding(findFieldset().element, 'gl-tooltip'); - beforeEach(() => { + const createComponent = ({ + shouldHideNewMrOption = false, + shouldDisableNewMrOption = false, + shouldCreateMR = false, + } = {}) => { store = createStore(); - store.state.currentProjectId = 'abcproject'; - - const proj = JSON.parse(JSON.stringify(projectData)); - proj.userPermissions[PERMISSION_CREATE_MR] = true; - Vue.set(store.state.projects, 'abcproject', proj); - }); + wrapper = shallowMountExtended(NewMergeRequestOption, { + store: { + ...store, + getters: { + 'commit/shouldHideNewMrOption': shouldHideNewMrOption, + 'commit/shouldDisableNewMrOption': shouldDisableNewMrOption, + 'commit/shouldCreateMR': shouldCreateMR, + }, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('for default branch', () => { - describe('is rendered when pushing to a new branch', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'main', - createNewBranch: true, - }); - }); - - it('has NO new MR', () => { - expect(vm.$el.textContent).not.toBe(''); - }); - - it('has new MR', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).not.toBe(''); - }); + describe('when the `shouldHideNewMrOption` getter returns false', () => { + beforeEach(() => { + createComponent(); + jest.spyOn(store, 'dispatch').mockImplementation(); }); - describe('is NOT rendered when pushing to the same branch', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'main', - createNewBranch: false, - }); - }); - - it('has NO new MR', () => { - expect(vm.$el.textContent).toBe(''); - }); - - it('has new MR', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).toBe(''); - }); + it('renders an enabled new MR checkbox', () => { + expect(findCheckbox().attributes('disabled')).toBeUndefined(); }); - }); - describe('for protected branch', () => { - describe('when user does not have the write access', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'protected/no-access', - }); - }); - - it('is rendered if MR does not exists', () => { - expect(vm.$el.textContent).not.toBe(''); - }); + it("doesn't add `is-disabled` class to the fieldset", () => { + expect(findFieldset().classes()).not.toContain('is-disabled'); + }); - it('is rendered if MR exists', async () => { - setMR(); + it('dispatches toggleShouldCreateMR when clicking checkbox', () => { + findCheckbox().vm.$emit('change'); - await nextTick(); - expect(vm.$el.textContent).not.toBe(''); - }); + expect(store.dispatch).toHaveBeenCalledWith('commit/toggleShouldCreateMR', undefined); }); - describe('when user has the write access', () => { + describe('when user cannot create an MR', () => { beforeEach(() => { createComponent({ - currentBranchId: 'protected/access', + shouldDisableNewMrOption: true, }); }); - it('is rendered if MR does not exist', () => { - expect(vm.$el.textContent).not.toBe(''); + it('disables the new MR checkbox', () => { + expect(findCheckbox().attributes('disabled')).toBe('true'); }); - it('is hidden if MR exists', async () => { - setMR(); + it('adds `is-disabled` class to the fieldset', () => { + expect(findFieldset().classes()).toContain('is-disabled'); + }); - await nextTick(); - expect(vm.$el.textContent).toBe(''); + it('shows a tooltip', () => { + expect(findTooltip().value).toBe(wrapper.vm.$options.i18n.tooltipText); }); }); }); - describe('for regular branch', () => { + describe('when the `shouldHideNewMrOption` getter returns true', () => { beforeEach(() => { createComponent({ - currentBranchId: 'regular', + shouldHideNewMrOption: true, }); }); - it('is rendered if no MR exists', () => { - expect(vm.$el.textContent).not.toBe(''); - }); - - it('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); + it("doesn't render the new MR checkbox", () => { + expect(findCheckbox().exists()).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)]]), - ); - }); }); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 405813be4e3..3d25ad075de 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -18,6 +18,7 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; @@ -50,6 +51,8 @@ import { runnersDataPaginated, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; @@ -78,6 +81,7 @@ describe('AdminRunnersApp', () => { const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); @@ -106,6 +110,8 @@ describe('AdminRunnersApp', () => { localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, ...provide, }, ...options, @@ -457,12 +463,28 @@ describe('AdminRunnersApp', () => { runners: { nodes: [] }, }, }); + createComponent(); await waitForPromises(); }); - it('shows a message for no results', async () => { - expect(wrapper.text()).toContain('No runners found'); + it('shows an empty state', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(false); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + await waitForPromises(); + }); + + it('shows an empty state for a filtered search', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(true); + }); }); }); diff --git a/spec/frontend/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/runner/components/cells/runner_status_cell_spec.js index 20a1cdf7236..0f5133d0ae2 100644 --- a/spec/frontend/runner/components/cells/runner_status_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_status_cell_spec.js @@ -1,12 +1,15 @@ -import { GlBadge } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import RunnerStatusCell from '~/runner/components/cells/runner_status_cell.vue'; + +import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue'; +import RunnerPausedBadge from '~/runner/components/runner_paused_badge.vue'; import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE } from '~/runner/constants'; -describe('RunnerTypeCell', () => { +describe('RunnerStatusCell', () => { let wrapper; - const findBadgeAt = (i) => wrapper.findAllComponents(GlBadge).at(i); + const findStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); + const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge); const createComponent = ({ runner = {} } = {}) => { wrapper = mount(RunnerStatusCell, { @@ -29,7 +32,7 @@ describe('RunnerTypeCell', () => { createComponent(); expect(wrapper.text()).toMatchInterpolatedText('online'); - expect(findBadgeAt(0).text()).toBe('online'); + expect(findStatusBadge().text()).toBe('online'); }); it('Displays offline status', () => { @@ -40,7 +43,7 @@ describe('RunnerTypeCell', () => { }); expect(wrapper.text()).toMatchInterpolatedText('offline'); - expect(findBadgeAt(0).text()).toBe('offline'); + expect(findStatusBadge().text()).toBe('offline'); }); it('Displays paused status', () => { @@ -52,9 +55,7 @@ describe('RunnerTypeCell', () => { }); expect(wrapper.text()).toMatchInterpolatedText('online paused'); - - expect(findBadgeAt(0).text()).toBe('online'); - expect(findBadgeAt(1).text()).toBe('paused'); + expect(findPausedBadge().text()).toBe('paused'); }); it('Is empty when data is missing', () => { diff --git a/spec/frontend/runner/components/runner_list_empty_state_spec.js b/spec/frontend/runner/components/runner_list_empty_state_spec.js new file mode 100644 index 00000000000..59cff863106 --- /dev/null +++ b/spec/frontend/runner/components/runner_list_empty_state_spec.js @@ -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')); + }); + }); +}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 52bd51a974b..eb9f85a7d0f 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -16,6 +16,7 @@ import { updateHistory } from '~/lib/utils/url_utility'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; @@ -48,6 +49,8 @@ import { groupRunnersCountData, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, } from '../mock_data'; Vue.use(VueApollo); @@ -75,6 +78,7 @@ describe('GroupRunnersApp', () => { const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`)); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); @@ -103,6 +107,8 @@ describe('GroupRunnersApp', () => { provide: { onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, }, }); }; @@ -388,8 +394,8 @@ describe('GroupRunnersApp', () => { await waitForPromises(); }); - it('shows a message for no results', async () => { - expect(wrapper.text()).toContain('No runners found'); + it('shows an empty state', async () => { + expect(findRunnerListEmptyState().exists()).toBe(true); }); }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 40854dae57a..3368fc21544 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -21,6 +21,9 @@ import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runne export const onlineContactTimeoutSecs = 2 * 60 * 60; export const staleTimeoutSecs = 7889238; // Ruby's `3.months` +export const emptyStateSvgPath = 'emptyStateSvgPath.svg'; +export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg'; + export { runnersData, runnersDataPaginated, diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index a3c1458ed26..1f102f86b2a 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -5,6 +5,7 @@ import { fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, + isSearchFiltered, } from '~/runner/runner_search_utils'; describe('search_params.js', () => { @@ -14,6 +15,7 @@ describe('search_params.js', () => { urlQuery: '', search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + isDefault: true, }, { name: 'a single status', @@ -268,7 +270,7 @@ describe('search_params.js', () => { describe('fromSearchToUrl', () => { examples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a url`, () => { - expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`); + expect(fromSearchToUrl(search)).toBe(`http://test.host/${urlQuery}`); }); }); @@ -280,7 +282,7 @@ describe('search_params.js', () => { const search = { filters: [], sort: 'CREATED_DESC' }; const expectedUrl = `http://test.host/`; - expect(fromSearchToUrl(search, initalUrl)).toEqual(expectedUrl); + expect(fromSearchToUrl(search, initalUrl)).toBe(expectedUrl); }); it('When unrelated search parameter is present, it does not get removed', () => { @@ -288,7 +290,7 @@ describe('search_params.js', () => { const search = { filters: [], sort: 'CREATED_DESC' }; const expectedUrl = `http://test.host/?unrelated=UNRELATED`; - expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl); + expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); }); }); @@ -331,4 +333,16 @@ describe('search_params.js', () => { }); }); }); + + describe('isSearchFiltered', () => { + examples.forEach(({ name, search, isDefault }) => { + it(`Given ${name}, evaluates to ${isDefault ? 'not ' : ''}filtered`, () => { + expect(isSearchFiltered(search)).toBe(!isDefault); + }); + }); + + it('given a missing pagination, evaluates as not filtered', () => { + expect(isSearchFiltered({ pagination: null })).toBe(false); + }); + }); }); diff --git a/spec/frontend_integration/ide/helpers/ide_helper.js b/spec/frontend_integration/ide/helpers/ide_helper.js index 0c3786929d8..20a1e5aceb2 100644 --- a/spec/frontend_integration/ide/helpers/ide_helper.js +++ b/spec/frontend_integration/ide/helpers/ide_helper.js @@ -207,10 +207,10 @@ export const commit = async ({ newBranch = false, newMR = false, newBranchName = if (!newBranch) { const option = await screen.findByLabelText(/Commit to .+ branch/); - option.click(); + await option.click(); } else { const option = await screen.findByLabelText('Create a new branch'); - option.click(); + await option.click(); const branchNameInput = await screen.findByTestId('ide-new-branch-name'); fireEvent.input(branchNameInput, { target: { value: newBranchName } }); diff --git a/spec/graphql/resolvers/concerns/resolves_groups_spec.rb b/spec/graphql/resolvers/concerns/resolves_groups_spec.rb index bfbbae29e92..d15c8f2ee42 100644 --- a/spec/graphql/resolvers/concerns/resolves_groups_spec.rb +++ b/spec/graphql/resolvers/concerns/resolves_groups_spec.rb @@ -27,11 +27,9 @@ RSpec.describe ResolvesGroups do let_it_be(:lookahead_fields) do <<~FIELDS - contacts { nodes { id } } containerRepositoriesCount customEmoji { nodes { id } } fullPath - organizations { nodes { id } } path dependencyProxyBlobCount dependencyProxyBlobs { nodes { fileName } } diff --git a/spec/graphql/resolvers/crm/contacts_resolver_spec.rb b/spec/graphql/resolvers/crm/contacts_resolver_spec.rb new file mode 100644 index 00000000000..eba26c8c71f --- /dev/null +++ b/spec/graphql/resolvers/crm/contacts_resolver_spec.rb @@ -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 diff --git a/spec/graphql/resolvers/crm/organizations_resolver_spec.rb b/spec/graphql/resolvers/crm/organizations_resolver_spec.rb new file mode 100644 index 00000000000..c80caf91f90 --- /dev/null +++ b/spec/graphql/resolvers/crm/organizations_resolver_spec.rb @@ -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 diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index cf62579338f..4d1b1c7682c 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -84,12 +84,14 @@ RSpec.describe Ci::RunnersHelper do end it 'returns the data in format' do - expect(helper.admin_runners_data_attributes).to eq({ + expect(helper.admin_runners_data_attributes).to include( runner_install_help_page: 'https://docs.gitlab.com/runner/install/', registration_token: Gitlab::CurrentSettings.runners_registration_token, online_contact_timeout_secs: 7200, - stale_timeout_secs: 7889238 - }) + stale_timeout_secs: 7889238, + empty_state_svg_path: start_with('/assets/illustrations/pipelines_empty'), + empty_state_filtered_svg_path: start_with('/assets/illustrations/magnifying-glass') + ) end end @@ -130,14 +132,16 @@ RSpec.describe Ci::RunnersHelper do let(:group) { create(:group) } it 'returns group data to render a runner list' do - expect(helper.group_runners_data_attributes(group)).to eq({ + expect(helper.group_runners_data_attributes(group)).to include( registration_token: group.runners_token, group_id: group.id, group_full_path: group.full_path, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', online_contact_timeout_secs: 7200, - stale_timeout_secs: 7889238 - }) + stale_timeout_secs: 7889238, + empty_state_svg_path: start_with('/assets/illustrations/pipelines_empty'), + empty_state_filtered_svg_path: start_with('/assets/illustrations/magnifying-glass') + ) end end diff --git a/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb b/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb new file mode 100644 index 00000000000..035ea6eadcf --- /dev/null +++ b/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb @@ -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 diff --git a/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb index 6ac07d10aba..69d809aee85 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb @@ -31,6 +31,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c context 'when the limit is exceeded' do before do stub_application_setting(pipeline_limit_per_project_user_sha: 1) + stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: false) end it 'does not persist the pipeline' do @@ -52,7 +53,9 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c class: described_class.name, project_id: project.id, subscription_plan: project.actual_plan_name, - commit_sha: command.sha + commit_sha: command.sha, + throttled: true, + throttle_override: false ) ) @@ -121,7 +124,42 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c class: described_class.name, project_id: project.id, subscription_plan: project.actual_plan_name, - commit_sha: command.sha + commit_sha: command.sha, + throttled: false, + throttle_override: false + ) + ) + + perform + end + end + + context 'when ci_enforce_throttle_pipelines_creation_override is enabled' do + before do + stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: true) + end + + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'creates a log entry' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + class: described_class.name, + project_id: project.id, + subscription_plan: project.actual_plan_name, + commit_sha: command.sha, + throttled: false, + throttle_override: true ) ) diff --git a/spec/migrations/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects_spec.rb b/spec/migrations/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects_spec.rb new file mode 100644 index 00000000000..e3bc832a10b --- /dev/null +++ b/spec/migrations/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects_spec.rb @@ -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 diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index b0111ba0214..8a6f45e0712 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -5071,6 +5071,18 @@ RSpec.describe Ci::Build do build.execute_hooks end + + context 'with blocked users' do + before do + allow(build).to receive(:user) { FactoryBot.build(:user, :blocked) } + end + + it 'does not call project.execute_hooks' do + expect(build.project).not_to receive(:execute_hooks) + + build.execute_hooks + end + end end context 'without project hooks' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 37399f29b27..7f53b765270 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -3056,7 +3056,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'hooks trigerring' do - let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline, :created) } %i[ enqueue @@ -3076,7 +3076,19 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do it 'schedules a new PipelineHooksWorker job' do expect(PipelineHooksWorker).to receive(:perform_async).with(pipeline.id) - pipeline.reload.public_send(pipeline_action) + pipeline.public_send(pipeline_action) + end + + context 'with blocked users' do + before do + allow(pipeline).to receive(:user) { build(:user, :blocked) } + end + + it 'does not schedule a new PipelineHooksWorker job' do + expect(PipelineHooksWorker).not_to receive(:perform_async) + + pipeline.public_send(pipeline_action) + end end end end @@ -3636,6 +3648,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do pipeline.succeed! end end + + context 'when the user is blocked' do + before do + pipeline.user.block! + end + + it 'does not enqueue PipelineNotificationWorker' do + expect(PipelineNotificationWorker).not_to receive(:perform_async) + + pipeline.succeed + end + end end context 'with failed pipeline' do @@ -3656,6 +3680,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do pipeline.drop end + + context 'when the user is blocked' do + before do + pipeline.user.block! + end + + it 'does not enqueue PipelineNotificationWorker' do + expect(PipelineNotificationWorker).not_to receive(:perform_async) + + pipeline.drop + end + end end context 'with skipped pipeline' do diff --git a/spec/services/ci/create_pipeline_service/rate_limit_spec.rb b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb index fbd8b41de63..0000296230f 100644 --- a/spec/services/ci/create_pipeline_service/rate_limit_spec.rb +++ b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb @@ -11,6 +11,7 @@ RSpec.describe Ci::CreatePipelineService, :freeze_time, :clean_gitlab_redis_rate before do stub_ci_pipeline_yaml_file(gitlab_ci_yaml) stub_application_setting(pipeline_limit_per_project_user_sha: 1) + stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: false) end context 'when user is under the limit' do diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index 777162b6196..cbbed82aa0b 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -205,6 +205,25 @@ RSpec.describe Projects::UpdatePagesService do include_examples 'fails with outdated reference message' end end + + context 'when uploaded deployment size is wrong' do + it 'raises an error' do + allow_next_instance_of(PagesDeployment) do |deployment| + allow(deployment) + .to receive(:size) + .and_return(file.size + 1) + end + + expect do + expect(execute).not_to eq(:success) + + expect(GenericCommitStatus.last.description).to eq("Error: The uploaded artifact size does not match the expected value.") + project.pages_metadatum.reload + expect(project.pages_metadatum).not_to be_deployed + expect(project.pages_metadatum.pages_deployment).to be_ni + end.to raise_error(Projects::UpdatePagesService::WrongUploadedDeploymentSizeError) + end + end end end diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb index 1f5d6ed5586..52f3fd60c07 100644 --- a/spec/support/shared_examples/features/runners_shared_examples.rb +++ b/spec/support/shared_examples/features/runners_shared_examples.rb @@ -62,7 +62,7 @@ RSpec.shared_examples 'shows and resets runner registration token' do end end -RSpec.shared_examples 'shows no runners' do +RSpec.shared_examples 'shows no runners registered' do it 'shows counts with 0' do expect(page).to have_text "Online runners 0" expect(page).to have_text "Offline runners 0" @@ -70,13 +70,19 @@ RSpec.shared_examples 'shows no runners' do end it 'shows "no runners" message' do - expect(page).to have_text 'No runners found' + expect(page).to have_text s_('Runners|Get started with runners') + end +end + +RSpec.shared_examples 'shows no runners found' do + it 'shows "no runners" message' do + expect(page).to have_text s_('Runners|No results found') end end RSpec.shared_examples 'shows runner in list' do it 'does not show empty state' do - expect(page).not_to have_content 'No runners found' + expect(page).not_to have_content s_('Runners|Get started with runners') end it 'shows runner row' do diff --git a/spec/workers/pipeline_hooks_worker_spec.rb b/spec/workers/pipeline_hooks_worker_spec.rb index 13a86c3d4fe..5d28b1e129a 100644 --- a/spec/workers/pipeline_hooks_worker_spec.rb +++ b/spec/workers/pipeline_hooks_worker_spec.rb @@ -25,6 +25,16 @@ RSpec.describe PipelineHooksWorker do .not_to raise_error end end + + context 'when the user is blocked' do + let(:pipeline) { create(:ci_pipeline, user: create(:user, :blocked)) } + + it 'returns early without executing' do + expect(Ci::Pipelines::HookService).not_to receive(:new) + + described_class.new.perform(pipeline.id) + end + end end it_behaves_like 'worker with data consistency', diff --git a/spec/workers/pipeline_notification_worker_spec.rb b/spec/workers/pipeline_notification_worker_spec.rb index 583c4bf1c0c..672debd0501 100644 --- a/spec/workers/pipeline_notification_worker_spec.rb +++ b/spec/workers/pipeline_notification_worker_spec.rb @@ -21,6 +21,20 @@ RSpec.describe PipelineNotificationWorker, :mailer do subject.perform(non_existing_record_id) end + context 'when the user is blocked' do + before do + expect_next_found_instance_of(Ci::Pipeline) do |pipeline| + allow(pipeline).to receive(:user) { build(:user, :blocked) } + end + end + + it 'does nothing' do + expect(NotificationService).not_to receive(:new) + + subject.perform(pipeline.id) + end + end + it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed diff --git a/yarn.lock b/yarn.lock index 1fca83a17b1..23f4d6a3a4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -963,10 +963,10 @@ stylelint-declaration-strict-value "1.8.0" stylelint-scss "4.1.0" -"@gitlab/svgs@2.17.0": - version "2.17.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.17.0.tgz#56d0d11744859b3e1da80dedab2396a95cd01a02" - integrity sha512-+cmn4ptdOFjSC8ByqD41kj1xSQ9/YFYLq/Es+jy5t12HmUtvYL8YRfNTlvApReSJ8SM7scwleVy4S19M15Siqw== +"@gitlab/svgs@2.18.0": + version "2.18.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.18.0.tgz#aafff929bc5365f7cad736b6d061895b3f9aa381" + integrity sha512-Okbm4dAAf/aiaRojUT57yfqY/TVka/zAXN4T+hOx/Yho6wUT2eAJ8CcFpctPdt3kUNM4bHU2CZYoGqklbtXkmg== "@gitlab/ui@40.7.1": version "40.7.1" |