diff options
120 files changed, 2418 insertions, 431 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index 12469c40b51..2f99c57f489 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -300,6 +300,14 @@ RSpec/TimecopFreeze: - 'ee/spec/**/*.rb' - 'qa/spec/**/*.rb' +RSpec/TimecopTravel: + Enabled: false + AutoCorrect: true + Include: + - 'spec/**/*.rb' + - 'ee/spec/**/*.rb' + - 'qa/spec/**/*.rb' + Naming/PredicateName: Enabled: true Exclude: diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 5c8df94ca90..eea81b729f9 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -17,9 +17,15 @@ export function formatIssue(issue) { export function formatListIssues(listIssues) { const issues = {}; + let listIssuesCount; const listData = listIssues.nodes.reduce((map, list) => { - const sortedIssues = sortBy(list.issues.nodes, 'relativePosition'); + listIssuesCount = list.issues.count; + let sortedIssues = list.issues.edges.map(issueNode => ({ + ...issueNode.node, + })); + sortedIssues = sortBy(sortedIssues, 'relativePosition'); + return { ...map, [list.id]: sortedIssues.map(i => { @@ -39,7 +45,17 @@ export function formatListIssues(listIssues) { }; }, {}); - return { listData, issues }; + return { listData, issues, listIssuesCount }; +} + +export function formatListsPageInfo(lists) { + const listData = lists.nodes.reduce((map, list) => { + return { + ...map, + [list.id]: list.issues.pageInfo, + }; + }, {}); + return listData; } export function fullBoardId(boardId) { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index d364e697fbe..d2e01b0156c 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -7,6 +7,7 @@ import Tooltip from '~/vue_shared/directives/tooltip'; import EmptyComponent from '~/vue_shared/components/empty_component'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import BoardList from './board_list.vue'; +import BoardListNew from './board_list_new.vue'; import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; @@ -16,7 +17,7 @@ export default { components: { BoardPromotionState: EmptyComponent, BoardListHeader, - BoardList, + BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList, }, directives: { Tooltip, @@ -72,7 +73,7 @@ export default { filter: { handler() { if (this.shouldFetchIssues) { - this.fetchIssuesForList(this.list.id); + this.fetchIssuesForList({ listId: this.list.id }); } else { this.list.page = 1; this.list.getIssues(true).catch(() => { @@ -85,7 +86,7 @@ export default { }, mounted() { if (this.shouldFetchIssues) { - this.fetchIssuesForList(this.list.id); + this.fetchIssuesForList({ listId: this.list.id }); } const instance = this; @@ -144,7 +145,6 @@ export default { :disabled="disabled" :issues="listIssues" :list="list" - :loading="list.loading" /> <!-- Will be only available in EE --> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 25f8ffca633..d01df44e7e4 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -14,6 +14,8 @@ import { sortableEnd, } from '../mixins/sortable_default_options'; +// This component is being replaced in favor of './board_list_new.vue' for GraphQL boards + if (gon.features && gon.features.multiSelectBoard) { Sortable.mount(new MultiDrag()); } @@ -39,10 +41,6 @@ export default { type: Array, required: true, }, - loading: { - type: Boolean, - required: true, - }, }, data() { return { @@ -62,6 +60,9 @@ export default { issuesSizeExceedsMax() { return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; }, + loading() { + return this.list.loading; + }, }, watch: { filters: { @@ -72,7 +73,6 @@ export default { deep: true, }, issues() { - if (this.glFeatures.graphqlBoardLists) return; this.$nextTick(() => { if ( this.scrollHeight() <= this.listHeight() && @@ -98,6 +98,8 @@ export default { eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, mounted() { + // TODO: Use Draggable in ./board_list_new.vue to drag & drop issue + // https://gitlab.com/gitlab-org/gitlab/-/issues/218164 const multiSelectOpts = {}; if (gon.features && gon.features.multiSelectBoard) { multiSelectOpts.multiDrag = true; @@ -403,8 +405,6 @@ export default { this.showIssueForm = !this.showIssueForm; }, onScroll() { - if (this.glFeatures.graphqlBoardLists) return; - if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) { this.loadNextPage(); } diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 73550fb9af6..127d3faf689 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -176,7 +176,6 @@ export default { <header :class="{ 'has-border': list.label && list.label.color, - 'gl-relative': list.isExpanded, 'gl-h-full': !list.isExpanded, 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, }" diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue new file mode 100644 index 00000000000..0a495d05122 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -0,0 +1,166 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import BoardNewIssue from './board_new_issue.vue'; +import BoardCard from './board_card.vue'; +import eventHub from '../eventhub'; +import boardsStore from '../stores/boards_store'; +import { sprintf, __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + name: 'BoardList', + components: { + BoardCard, + BoardNewIssue, + GlLoadingIcon, + }, + mixins: [glFeatureFlagMixin()], + props: { + disabled: { + type: Boolean, + required: true, + }, + list: { + type: Object, + required: true, + }, + issues: { + type: Array, + required: true, + }, + }, + data() { + return { + scrollOffset: 250, + filters: boardsStore.state.filters, + showCount: false, + showIssueForm: false, + }; + }, + computed: { + ...mapState(['pageInfoByListId', 'listsFlags']), + paginatedIssueText() { + return sprintf(__('Showing %{pageSize} of %{total} issues'), { + pageSize: this.issues.length, + total: this.list.issuesSize, + }); + }, + issuesSizeExceedsMax() { + return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; + }, + hasNextPage() { + return this.pageInfoByListId[this.list.id].hasNextPage; + }, + loading() { + return this.listsFlags[this.list.id]?.isLoading; + }, + }, + watch: { + filters: { + handler() { + this.list.loadingMore = false; + this.$refs.list.scrollTop = 0; + }, + deep: true, + }, + issues() { + this.$nextTick(() => { + this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); + }); + }, + }, + created() { + eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); + }, + mounted() { + // Scroll event on list to load more + this.$refs.list.addEventListener('scroll', this.onScroll); + }, + beforeDestroy() { + eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); + this.$refs.list.removeEventListener('scroll', this.onScroll); + }, + methods: { + ...mapActions(['fetchIssuesForList']), + listHeight() { + return this.$refs.list.getBoundingClientRect().height; + }, + scrollHeight() { + return this.$refs.list.scrollHeight; + }, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); + }, + scrollToTop() { + this.$refs.list.scrollTop = 0; + }, + loadNextPage() { + const loadingDone = () => { + this.list.loadingMore = false; + }; + this.list.loadingMore = true; + this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }) + .then(loadingDone) + .catch(loadingDone); + }, + toggleForm() { + this.showIssueForm = !this.showIssueForm; + }, + onScroll() { + window.requestAnimationFrame(() => { + if ( + !this.list.loadingMore && + this.scrollTop() > this.scrollHeight() - this.scrollOffset && + this.hasNextPage + ) { + this.loadNextPage(); + } + }); + }, + }, +}; +</script> + +<template> + <div + v-show="list.isExpanded" + class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column" + data-qa-selector="board_list_cards_area" + > + <div + v-if="loading" + class="gl-mt-4 gl-text-center" + :aria-label="__('Loading issues')" + data-testid="board_list_loading" + > + <gl-loading-icon /> + </div> + <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> + <ul + v-show="!loading" + ref="list" + :data-board="list.id" + :data-board-type="list.type" + :class="{ 'bg-danger-100': issuesSizeExceedsMax }" + class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" + > + <board-card + v-for="(issue, index) in issues" + ref="issue" + :key="issue.id" + :index="index" + :list="list" + :issue="issue" + :disabled="disabled" + /> + <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> + <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> + <span v-if="issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> + <span v-else>{{ paginatedIssueText }}</span> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 8658f51e5cf..a181ea51c4a 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -41,14 +41,7 @@ export default { default: false, }, }, - inject: { - groupId: { - type: Number, - }, - rootPath: { - type: String, - }, - }, + inject: ['groupId', 'rootPath'], data() { return { limitBeforeCounter: 2, diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 2af96e94d32..dc4ccc93951 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -161,6 +161,7 @@ export default () => { 'fetchEpicsSwimlanes', 'resetIssues', 'resetEpics', + 'fetchLists', ]), initialBoardLoad() { boardsStore @@ -183,7 +184,10 @@ export default () => { this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search))); if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) { this.resetEpics(); - this.fetchEpicsSwimlanes({ withLists: false }); + this.resetIssues(); + this.fetchEpicsSwimlanes({}); + } else if (gon.features.graphqlBoardLists && !this.isShowingEpicsSwimlanes) { + this.fetchLists(); this.resetIssues(); } }, diff --git a/app/assets/javascripts/boards/queries/board_lists.query.graphql b/app/assets/javascripts/boards/queries/board_lists.query.graphql new file mode 100644 index 00000000000..88425e9a9c1 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_lists.query.graphql @@ -0,0 +1,28 @@ +#import "ee_else_ce/boards/queries/board_list.fragment.graphql" + +query ListIssues( + $fullPath: ID! + $boardId: ID! + $filters: BoardIssueInput + $isGroup: Boolean = false + $isProject: Boolean = false +) { + group(fullPath: $fullPath) @include(if: $isGroup) { + board(id: $boardId) { + lists(issueFilters: $filters) { + nodes { + ...BoardListFragment + } + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + board(id: $boardId) { + lists(issueFilters: $filters) { + nodes { + ...BoardListFragment + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/group_board.query.graphql b/app/assets/javascripts/boards/queries/group_board.query.graphql deleted file mode 100644 index cb42cb3f73d..00000000000 --- a/app/assets/javascripts/boards/queries/group_board.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "ee_else_ce/boards/queries/board_list.fragment.graphql" - -query GroupBoard($fullPath: ID!, $boardId: ID!) { - group(fullPath: $fullPath) { - board(id: $boardId) { - lists { - nodes { - ...BoardListFragment - } - } - } - } -} diff --git a/app/assets/javascripts/boards/queries/lists_issues.query.graphql b/app/assets/javascripts/boards/queries/lists_issues.query.graphql index c66cdf68cf4..5dbfe4675c6 100644 --- a/app/assets/javascripts/boards/queries/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/queries/lists_issues.query.graphql @@ -7,15 +7,24 @@ query ListIssues( $filters: BoardIssueInput $isGroup: Boolean = false $isProject: Boolean = false + $after: String + $first: Int ) { group(fullPath: $fullPath) @include(if: $isGroup) { board(id: $boardId) { lists(id: $id) { nodes { id - issues(filters: $filters) { - nodes { - ...IssueNode + issues(first: $first, filters: $filters, after: $after) { + count + edges { + node { + ...IssueNode + } + } + pageInfo { + endCursor + hasNextPage } } } @@ -27,9 +36,16 @@ query ListIssues( lists(id: $id) { nodes { id - issues(filters: $filters) { - nodes { - ...IssueNode + issues(first: $first, filters: $filters, after: $after) { + count + edges { + node { + ...IssueNode + } + } + pageInfo { + endCursor + hasNextPage } } } diff --git a/app/assets/javascripts/boards/queries/project_board.query.graphql b/app/assets/javascripts/boards/queries/project_board.query.graphql deleted file mode 100644 index 4620a7e0fd5..00000000000 --- a/app/assets/javascripts/boards/queries/project_board.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "ee_else_ce/boards/queries/board_list.fragment.graphql" - -query ProjectBoard($fullPath: ID!, $boardId: ID!) { - project(fullPath: $fullPath) { - board(id: $boardId) { - lists { - nodes { - ...BoardListFragment - } - } - } - } -} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index a513e02e0ca..292f4d3307a 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -3,16 +3,15 @@ import { sortBy, pick } from 'lodash'; import createFlash from '~/flash'; import { __ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; -import createDefaultClient from '~/lib/graphql'; +import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { BoardType, ListType, inactiveId } from '~/boards/constants'; import * as types from './mutation_types'; -import { formatListIssues, fullBoardId } from '../boards_util'; +import { formatListIssues, fullBoardId, formatListsPageInfo } from '../boards_util'; import boardStore from '~/boards/stores/boards_store'; import listsIssuesQuery from '../queries/lists_issues.query.graphql'; -import projectBoardQuery from '../queries/project_board.query.graphql'; -import groupBoardQuery from '../queries/group_board.query.graphql'; +import boardListsQuery from '../queries/board_lists.query.graphql'; import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; @@ -22,7 +21,12 @@ const notImplemented = () => { throw new Error('Not implemented!'); }; -export const gqlClient = createDefaultClient(); +export const gqlClient = createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, +); export default { setInitialBoardData: ({ commit }, data) => { @@ -50,27 +54,20 @@ export default { }, fetchLists: ({ commit, state, dispatch }) => { - const { endpoints, boardType } = state; + const { endpoints, boardType, filterParams } = state; const { fullPath, boardId } = endpoints; - let query; - if (boardType === BoardType.group) { - query = groupBoardQuery; - } else if (boardType === BoardType.project) { - query = projectBoardQuery; - } else { - createFlash(__('Invalid board')); - return Promise.reject(); - } - const variables = { fullPath, boardId: fullBoardId(boardId), + filters: filterParams, + isGroup: boardType === BoardType.group, + isProject: boardType === BoardType.project, }; return gqlClient .query({ - query, + query: boardListsQuery, variables, }) .then(({ data }) => { @@ -197,7 +194,9 @@ export default { notImplemented(); }, - fetchIssuesForList: ({ state, commit }, listId) => { + fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { + commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext }); + const { endpoints, boardType, filterParams } = state; const { fullPath, boardId } = endpoints; @@ -208,6 +207,8 @@ export default { filters: filterParams, isGroup: boardType === BoardType.group, isProject: boardType === BoardType.project, + first: 20, + after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined, }; return gqlClient @@ -221,7 +222,8 @@ export default { .then(({ data }) => { const { lists } = data[boardType]?.board; const listIssues = formatListIssues(lists); - commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listId }); + const listPageInfo = formatListsPageInfo(lists); + commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId }); }) .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId)); }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 7e0597f5332..8bf8fb2e7b4 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -12,6 +12,7 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST'; export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS'; export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR'; +export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST'; export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE'; export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index d60ef459232..773f4a32c1d 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { sortBy, pull } from 'lodash'; +import { sortBy, pull, union } from 'lodash'; import { formatIssue, moveIssueListHelper } from '../boards_util'; import * as mutationTypes from './mutation_types'; import { s__ } from '~/locale'; @@ -99,20 +99,30 @@ export default { notImplemented(); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (state, { listIssues, listId }) => { + [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { + Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); + }, + + [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: ( + state, + { listIssues, listPageInfo, listId }, + ) => { const { listData, issues } = listIssues; Vue.set(state, 'issues', { ...state.issues, ...issues }); - Vue.set(state.issuesByListId, listId, listData[listId]); - const listIndex = state.boardLists.findIndex(l => l.id === listId); - Vue.set(state.boardLists[listIndex], 'loading', false); + Vue.set( + state.issuesByListId, + listId, + union(state.issuesByListId[listId] || [], listData[listId]), + ); + Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]); + Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => { state.error = s__( 'Boards|An error occurred while fetching the board issues. Please reload the page.', ); - const listIndex = state.boardLists.findIndex(l => l.id === listId); - Vue.set(state.boardLists[listIndex], 'loading', false); + Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, [mutationTypes.RESET_ISSUES]: state => { diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 2d388739586..ff1330f00f4 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -9,7 +9,9 @@ export default () => ({ activeId: inactiveId, sidebarType: '', boardLists: [], + listsFlags: {}, issuesByListId: {}, + pageInfoByListId: {}, issues: {}, filterParams: {}, error: undefined, diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue new file mode 100644 index 00000000000..e396521ce7c --- /dev/null +++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue @@ -0,0 +1,139 @@ +<script> +import { GlToggle, GlLoadingIcon, GlTooltip, GlAlert } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { + DEBOUNCE_TOGGLE_DELAY, + ERROR_MESSAGE, + ENABLED, + DISABLED, + ALLOW_OVERRIDE, +} from '../constants'; + +export default { + components: { + GlToggle, + GlLoadingIcon, + GlTooltip, + GlAlert, + }, + props: { + updatePath: { + type: String, + required: true, + }, + sharedRunnersAvailability: { + type: String, + required: true, + }, + parentSharedRunnersAvailability: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isLoading: false, + enabled: true, + allowOverride: false, + error: null, + }; + }, + computed: { + toggleDisabled() { + return this.parentSharedRunnersAvailability === DISABLED || this.isLoading; + }, + enabledOrDisabledSetting() { + return this.enabled ? ENABLED : DISABLED; + }, + disabledWithOverrideSetting() { + return this.allowOverride ? ALLOW_OVERRIDE : DISABLED; + }, + }, + created() { + if (this.sharedRunnersAvailability !== ENABLED) { + this.enabled = false; + } + + if (this.sharedRunnersAvailability === ALLOW_OVERRIDE) { + this.allowOverride = true; + } + }, + methods: { + generatePayload(data) { + return { shared_runners_setting: data }; + }, + enableOrDisable() { + this.updateRunnerSettings(this.generatePayload(this.enabledOrDisabledSetting)); + + // reset override toggle to false if shared runners are enabled + this.allowOverride = false; + }, + override() { + this.updateRunnerSettings(this.generatePayload(this.disabledWithOverrideSetting)); + }, + updateRunnerSettings: debounce(function debouncedUpdateRunnerSettings(setting) { + this.isLoading = true; + + axios + .put(this.updatePath, setting) + .then(() => { + this.isLoading = false; + }) + .catch(error => { + const message = [ + error.response?.data?.error || __('An error occurred while updating configuration.'), + ERROR_MESSAGE, + ].join(' '); + + this.error = message; + }); + }, DEBOUNCE_TOGGLE_DELAY), + }, +}; +</script> + +<template> + <div ref="sharedRunnersForm"> + <gl-alert v-if="error" variant="danger" :dismissible="false">{{ error }}</gl-alert> + + <h4 class="gl-display-flex gl-align-items-center"> + {{ __('Set up shared runner availability') }} + <gl-loading-icon v-if="isLoading" class="gl-ml-3" inline /> + </h4> + + <section class="gl-mt-5"> + <gl-toggle + v-model="enabled" + :disabled="toggleDisabled" + :label="__('Enable shared runners for this group')" + data-testid="enable-runners-toggle" + @change="enableOrDisable" + /> + + <span class="gl-text-gray-600"> + {{ __('Enable shared runners for all projects and subgroups in this group.') }} + </span> + </section> + + <section v-if="!enabled" class="gl-mt-5"> + <gl-toggle + v-model="allowOverride" + :disabled="toggleDisabled" + :label="__('Allow projects and subgroups to override the group setting')" + data-testid="override-runners-toggle" + @change="override" + /> + + <span class="gl-text-gray-600"> + {{ __('Allows projects or subgroups in this group to override the global setting.') }} + </span> + </section> + + <gl-tooltip v-if="toggleDisabled" :target="() => $refs.sharedRunnersForm"> + {{ __('Shared runners are disabled for the parent group') }} + </gl-tooltip> + </div> +</template> diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js new file mode 100644 index 00000000000..c7bb851c06b --- /dev/null +++ b/app/assets/javascripts/group_settings/constants.js @@ -0,0 +1,11 @@ +import { __ } from '~/locale'; + +// Debounce delay in milliseconds +export const DEBOUNCE_TOGGLE_DELAY = 1000; + +export const ERROR_MESSAGE = __('Refresh the page and try again.'); + +// runner setting options +export const ENABLED = 'enabled'; +export const DISABLED = 'disabled_and_unoverridable'; +export const ALLOW_OVERRIDE = 'disabled_with_override'; diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js new file mode 100644 index 00000000000..44284204c41 --- /dev/null +++ b/app/assets/javascripts/group_settings/mount_shared_runners.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import UpdateSharedRunnersForm from './components/shared_runners_form.vue'; + +export default (containerId = 'update-shared-runners-form') => { + const containerEl = document.getElementById(containerId); + + return new Vue({ + el: containerEl, + render(createElement) { + return createElement(UpdateSharedRunnersForm, { + props: containerEl.dataset, + }); + }, + }); +}; diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue index 69dd494f11a..2789be30818 100644 --- a/app/assets/javascripts/packages/details/components/package_title.vue +++ b/app/assets/javascripts/packages/details/components/package_title.vue @@ -54,15 +54,15 @@ export default { </gl-sprintf> </template> - <template v-if="packageTypeDisplay" #metadata_type> + <template v-if="packageTypeDisplay" #metadata-type> <metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" /> </template> - <template #metadata_size> + <template #metadata-size> <metadata-item data-testid="package-size" icon="disk" :text="totalSize" /> </template> - <template v-if="packagePipeline" #metadata_pipeline> + <template v-if="packagePipeline" #metadata-pipeline> <metadata-item data-testid="pipeline-project" icon="review-list" @@ -71,11 +71,11 @@ export default { /> </template> - <template v-if="packagePipeline" #metadata_ref> + <template v-if="packagePipeline" #metadata-ref> <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" /> </template> - <template v-if="hasTagsToDisplay" #metadata_tags> + <template v-if="hasTagsToDisplay" #metadata-tags> <package-tags :tag-display-limit="2" :tags="packageEntity.tags" hide-label /> </template> diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue index e5cab310bc8..f94a98e4ca7 100644 --- a/app/assets/javascripts/packages/list/components/package_title.vue +++ b/app/assets/javascripts/packages/list/components/package_title.vue @@ -40,7 +40,7 @@ export default { <template> <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages"> - <template #metadata_amount> + <template #metadata-amount> <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" /> </template> </title-area> diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index ae481d16ee9..4d0a03e151a 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -8,18 +8,16 @@ import initManualOrdering from '~/manual_ordering'; const ISSUE_BULK_UPDATE_PREFIX = 'issue_'; -document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); - issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); +IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); +issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); - initIssuablesList(); +initIssuablesList(); - initFilteredSearch({ - page: FILTERED_SEARCH.ISSUES, - isGroupDecendent: true, - useDefaultState: true, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - }); - projectSelect(); - initManualOrdering(); +initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + isGroupDecendent: true, + useDefaultState: true, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); +projectSelect(); +initManualOrdering(); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index add483843df..67eb09da5e0 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -4,6 +4,7 @@ import initVariableList from '~/ci_variable_list'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; +import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -29,4 +30,6 @@ document.addEventListener('DOMContentLoaded', () => { maskableRegex: variableListEl.dataset.maskableRegex, }); } + + initSharedRunnersForm(); }); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index e1add4a2af3..f3ccedc47c8 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -11,20 +11,18 @@ import initIssuablesList from '~/issues_list'; import initManualOrdering from '~/manual_ordering'; import { showLearnGitLabIssuesPopover } from '~/onboarding_issues'; -document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); +IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); - initFilteredSearch({ - page: FILTERED_SEARCH.ISSUES, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - useDefaultState: true, - }); +initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, +}); - new IssuableIndex(ISSUABLE_INDEX.ISSUE); - new ShortcutsNavigation(); - new UsersSelect(); +new IssuableIndex(ISSUABLE_INDEX.ISSUE); +new ShortcutsNavigation(); +new UsersSelect(); - initManualOrdering(); - initIssuablesList(); - showLearnGitLabIssuesPopover(); -}); +initManualOrdering(); +initIssuablesList(); +showLearnGitLabIssuesPopover(); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index aecc6484b26..48afd2142ee 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,3 @@ import initForm from 'ee_else_ce/pages/projects/issues/form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index e0c1332796f..231ee6732e9 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,17 +1,15 @@ import FilteredSearchServiceDesk from './filtered_search'; import initIssuablesList from '~/issues_list'; -document.addEventListener('DOMContentLoaded', () => { - const supportBotData = JSON.parse( - document.querySelector('.js-service-desk-issues').dataset.supportBot, - ); +const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, +); - if (document.querySelector('.filtered-search')) { - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); - filteredSearchManager.setup(); - } +if (document.querySelector('.filtered-search')) { + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); +} - if (gon.features?.vueIssuablesList) { - initIssuablesList(); - } -}); +if (gon.features?.vueIssuablesList) { + initIssuablesList(); +} diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index aef4feef42c..630add51a97 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -2,10 +2,8 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initRelatedIssues from '~/related_issues'; import initShow from '../show'; -document.addEventListener('DOMContentLoaded', () => { - initShow(); - if (gon.features && !gon.features.vueIssuableSidebar) { - initSidebarBundle(); - } - initRelatedIssues(); -}); +initShow(); +if (gon.features && !gon.features.vueIssuableSidebar) { + initSidebarBundle(); +} +initRelatedIssues(); diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 8d48430560e..aae3bf8161d 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -171,7 +171,7 @@ export default { /> </template> - <template v-if="!invalidTag" #details_published> + <template v-if="!invalidTag" #details-published> <details-row icon="clock" data-testid="published-date-detail"> <gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT"> <template #repositoryPath> @@ -186,7 +186,7 @@ export default { </gl-sprintf> </details-row> </template> - <template v-if="!invalidTag" #details_manifest_digest> + <template v-if="!invalidTag" #details-manifest-digest> <details-row icon="log" data-testid="manifest-detail"> <gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST"> <template #digest> @@ -202,7 +202,7 @@ export default { /> </details-row> </template> - <template v-if="!invalidTag" #details_configuration_digest> + <template v-if="!invalidTag" #details-configuration-digest> <details-row icon="cloud-gear" data-testid="configuration-detail"> <gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST"> <template #digest> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue index 228a660c997..c2bd01701df 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue @@ -96,7 +96,7 @@ export default { <template #right-actions> <slot name="commands"></slot> </template> - <template #metadata_count> + <template #metadata-count> <metadata-item v-if="imagesCount" data-testid="images-count" @@ -104,7 +104,7 @@ export default { :text="imagesCountText" /> </template> - <template #metadata_exp_policies> + <template #metadata-exp-policies> <metadata-item v-if="!hideExpirationPolicyData" data-testid="expiration-policy" diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue new file mode 100644 index 00000000000..927b52dc72a --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue @@ -0,0 +1,67 @@ +<script> +import { GlForm, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui'; + +export default { + components: { + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + }, + props: { + title: { + type: String, + required: true, + }, + description: { + type: String, + required: true, + }, + }, + data() { + return { + editable: { + title: this.title, + description: this.description, + }, + }; + }, + methods: { + getId(type, key) { + return `sse-merge-request-meta-${type}-${key}`; + }, + onUpdate() { + this.$emit('updateSettings', { ...this.editable }); + }, + }, +}; +</script> + +<template> + <gl-form> + <gl-form-group + key="title" + :label="__('Brief title about the change')" + :label-for="getId('control', 'title')" + > + <gl-form-input + :id="getId('control', 'title')" + v-model.lazy="editable.title" + type="text" + @input="onUpdate" + /> + </gl-form-group> + + <gl-form-group + key="description" + :label="__('Goal of the changes and what reviewers should be aware of')" + :label-for="getId('control', 'description')" + > + <gl-form-textarea + :id="getId('control', 'description')" + v-model.lazy="editable.description" + @input="onUpdate" + /> + </gl-form-group> + </gl-form> +</template> diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue new file mode 100644 index 00000000000..aa4c0eb7f1c --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue @@ -0,0 +1,69 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; + +import EditMetaControls from './edit_meta_controls.vue'; + +export default { + components: { + GlModal, + EditMetaControls, + }, + props: { + sourcePath: { + type: String, + required: true, + }, + }, + data() { + return { + mergeRequestMeta: { + title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { + sourcePath: this.sourcePath, + }), + description: s__('StaticSiteEditor|Copy update'), + }, + }; + }, + computed: { + disabled() { + return this.mergeRequestMeta.title === ''; + }, + primaryProps() { + return { + text: __('Submit changes'), + attributes: [{ variant: 'success' }, { disabled: this.disabled }], + }; + }, + }, + methods: { + hide() { + this.$refs.modal.hide(); + }, + show() { + this.$refs.modal.show(); + }, + onUpdateSettings(mergeRequestMeta) { + this.mergeRequestMeta = { ...mergeRequestMeta }; + }, + }, +}; +</script> + +<template> + <gl-modal + ref="modal" + modal-id="edit-meta-modal" + :title="__('Submit your changes')" + :action-primary="primaryProps" + size="sm" + @primary="() => $emit('primary', mergeRequestMeta)" + @hide="() => $emit('hide')" + > + <edit-meta-controls + :title="mergeRequestMeta.title" + :description="mergeRequestMeta.description" + @updateSettings="onUpdateSettings" + /> + </gl-modal> +</template> diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue index 5f00f9f22f3..3bb5a0b8fd5 100644 --- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue +++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue @@ -50,7 +50,7 @@ export default { :loading="savingChanges" @click="$emit('submit')" > - {{ __('Submit changes') }} + {{ __('Submit changes...') }} </gl-button> </div> </div> diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index d48917e8f36..27bd1c99ae2 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -1,10 +1,10 @@ <script> import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import SkeletonLoader from '../components/skeleton_loader.vue'; import EditArea from '../components/edit_area.vue'; +import EditMetaModal from '../components/edit_meta_modal.vue'; import InvalidContentMessage from '../components/invalid_content_message.vue'; import SubmitChangesError from '../components/submit_changes_error.vue'; import appDataQuery from '../graphql/queries/app_data.query.graphql'; @@ -18,6 +18,7 @@ export default { components: { SkeletonLoader, EditArea, + EditMetaModal, InvalidContentMessage, SubmitChangesError, }, @@ -51,6 +52,7 @@ export default { data() { return { content: null, + images: null, submitChangesError: null, isSavingChanges: false, }; @@ -67,16 +69,21 @@ export default { Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR); }, methods: { + onHideModal() { + this.isSavingChanges = false; + this.$refs.editMetaModal.hide(); + }, onDismissError() { this.submitChangesError = null; }, - onSubmit({ content, images }) { + onPrepareSubmit({ content, images }) { this.content = content; - this.submitChanges(images); - }, - submitChanges(images) { - this.isSavingChanges = true; + this.images = images; + this.isSavingChanges = true; + this.$refs.editMetaModal.show(); + }, + onSubmit(mergeRequestMeta) { // eslint-disable-next-line promise/catch-or-return this.$apollo .mutate({ @@ -100,13 +107,8 @@ export default { username: this.appData.username, sourcePath: this.appData.sourcePath, content: this.content, - images, - mergeRequestMeta: { - title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { - sourcePath: this.appData.sourcePath, - }), - description: s__('StaticSiteEditor|Copy update'), - }, + images: this.images, + mergeRequestMeta, }, }, }) @@ -127,7 +129,7 @@ export default { <submit-changes-error v-if="submitChangesError" :error="submitChangesError" - @retry="submitChanges" + @retry="onSubmit" @dismiss="onDismissError" /> <edit-area @@ -136,7 +138,13 @@ export default { :content="sourceContent.content" :saving-changes="isSavingChanges" :return-url="appData.returnUrl" - @submit="onSubmit" + @submit="onPrepareSubmit" + /> + <edit-meta-modal + ref="editMetaModal" + :source-path="appData.sourcePath" + @primary="onSubmit" + @hide="onHideModal" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue index 9c91f012d14..2b0a75640e2 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue @@ -1,11 +1,12 @@ <script> import ActionButtonGroup from './action_button_group.vue'; import RemoveMemberButton from './remove_member_button.vue'; +import ResendInviteButton from './resend_invite_button.vue'; import { s__, sprintf } from '~/locale'; export default { name: 'InviteActionButtons', - components: { ActionButtonGroup, RemoveMemberButton }, + components: { ActionButtonGroup, RemoveMemberButton, ResendInviteButton }, props: { member: { type: Object, @@ -33,7 +34,9 @@ export default { <template> <action-button-group> - <!-- Resend button will go here --> + <div v-if="permissions.canResend" class="gl-px-1"> + <resend-invite-button :member-id="member.id" /> + </div> <div v-if="permissions.canRemove" class="gl-px-1"> <remove-member-button :member-id="member.id" diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue new file mode 100644 index 00000000000..1cc3fd17e98 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue @@ -0,0 +1,41 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; + +export default { + name: 'ResendInviteButton', + csrf, + title: __('Resend invite'), + components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberId: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState(['memberPath']), + resendPath() { + return this.memberPath.replace(/:id$/, `${this.memberId}/resend_invite`); + }, + }, +}; +</script> + +<template> + <form :action="resendPath" method="post"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-button + v-gl-tooltip.hover + :title="$options.title" + :aria-label="$options.title" + icon="paper-airplane" + type="submit" + /> + </form> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue index aeb2de8e2b3..1ffba579f40 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue @@ -41,6 +41,9 @@ export default { canRemove() { return this.isDirectMember && this.member.canRemove; }, + canResend() { + return Boolean(this.member.invite?.canResend); + }, }, render() { return this.$scopedSlots.default({ @@ -49,6 +52,7 @@ export default { isCurrentUser: this.isCurrentUser, permissions: { canRemove: this.canRemove, + canResend: this.canResend, }, }); }, diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 50a19dc2156..7046ac5be03 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -39,7 +39,7 @@ export default { }, }, mounted() { - this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details_')); + this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details-')); }, methods: { toggleDetails() { diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index 197671b47d6..06b4309ad42 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -31,7 +31,7 @@ export default { }; }, mounted() { - this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata_')); + this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata-')); }, }; </script> diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index b30f54acf90..b1c9a412cc7 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -12,6 +12,8 @@ module Projects push_frontend_feature_flag(:ajax_new_deploy_token, @project) end + helper_method :highlight_badge + def show if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project) @triggers_json = ::Ci::TriggerSerializer.new.represent( @@ -60,6 +62,10 @@ module Projects private + def highlight_badge(name, content, language = nil) + Gitlab::Highlight.highlight(name, content, language: language) + end + def update_params params.require(:project).permit(*permitted_project_params) end diff --git a/app/controllers/projects/tracings_controller.rb b/app/controllers/projects/tracings_controller.rb new file mode 100644 index 00000000000..85098b66997 --- /dev/null +++ b/app/controllers/projects/tracings_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Projects + class TracingsController < Projects::ApplicationController + content_security_policy do |p| + next if p.directives.blank? + + global_frame_src = p.frame_src + + p.frame_src -> { frame_src_csp_policy(global_frame_src) } + end + + before_action :authorize_update_environment! + + def show + end + + private + + def frame_src_csp_policy(global_frame_src) + external_url = @project&.tracing_setting&.external_url + + external_url.presence || global_frame_src + end + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 99b97bca73d..139ef5e0692 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -6,6 +6,8 @@ class RegistrationsController < Devise::RegistrationsController include RecaptchaExperimentHelper include InvisibleCaptchaOnSignup + BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze + layout :choose_layout skip_before_action :required_signup_info, :check_two_factor_requirement, only: [:welcome, :update_registration] @@ -28,6 +30,7 @@ class RegistrationsController < Devise::RegistrationsController end def create + set_user_state accept_pending_invitations super do |new_user| @@ -37,9 +40,9 @@ class RegistrationsController < Devise::RegistrationsController yield new_user if block_given? end - # Devise sets a flash message on `create` for a successful signup, - # which we don't want to show. - flash[:notice] = nil + # Devise sets a flash message on both successful & failed signups, + # but we only want to show a message if the resource is blocked by a pending approval. + flash[:notice] = nil unless resource.blocked_pending_approval? rescue Gitlab::Access::AccessDeniedError redirect_to(new_user_session_path) end @@ -121,6 +124,8 @@ class RegistrationsController < Devise::RegistrationsController def after_inactive_sign_up_path_for(resource) Gitlab::AppLogger.info(user_created_message) + return new_user_session_path(anchor: 'login-pane') if resource.blocked_pending_approval? + Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path end @@ -235,6 +240,13 @@ class RegistrationsController < Devise::RegistrationsController !helpers.in_oauth_flow? && !helpers.in_trial_flow? end + + def set_user_state + return unless Feature.enabled?(:admin_approval_for_new_user_signups) + return unless Gitlab::CurrentSettings.require_admin_approval_after_user_signup + + resource.state = BLOCKED_PENDING_APPROVAL_STATE + end end RegistrationsController.prepend_if_ee('EE::RegistrationsController') diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 5bc01cbb354..0380bc1c548 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -41,6 +41,7 @@ class SearchController < ApplicationController @show_snippets = search_service.show_snippets? @search_results = search_service.search_results @search_objects = search_service.search_objects(preload_method) + @search_highlight = search_service.search_highlight render_commits if @scope == 'commits' eager_load_user_status if @scope == 'users' diff --git a/app/finders/alert_management/alerts_finder.rb b/app/finders/alert_management/alerts_finder.rb index cb35be43c15..e3ff7191ea5 100644 --- a/app/finders/alert_management/alerts_finder.rb +++ b/app/finders/alert_management/alerts_finder.rb @@ -2,8 +2,8 @@ module AlertManagement class AlertsFinder - # @return [Hash<Integer,Integer>] Mapping of status id to count - # ex) { 0: 6, ...etc } + # @return [Hash<Symbol,Integer>] Mapping of status id to count + # ex) { triggered: 6, ...etc } def self.counts_by_status(current_user, project, params = {}) new(current_user, project, params).execute.counts_by_status end @@ -35,7 +35,7 @@ module AlertManagement end def by_status(collection) - values = AlertManagement::Alert::STATUSES.values & Array(params[:status]) + values = AlertManagement::Alert.status_names & Array(params[:status]) values.present? ? collection.for_status(values) : collection end diff --git a/app/graphql/types/alert_management/alert_status_counts_type.rb b/app/graphql/types/alert_management/alert_status_counts_type.rb index f80b289eabc..a84be705445 100644 --- a/app/graphql/types/alert_management/alert_status_counts_type.rb +++ b/app/graphql/types/alert_management/alert_status_counts_type.rb @@ -9,11 +9,11 @@ module Types authorize :read_alert_management_alert - ::Gitlab::AlertManagement::AlertStatusCounts::STATUSES.each_key do |status| + ::AlertManagement::Alert.status_names.each do |status| field status, GraphQL::INT_TYPE, null: true, - description: "Number of alerts with status #{status.upcase} for the project" + description: "Number of alerts with status #{status.to_s.upcase} for the project" end field :open, diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 2da97030b88..f4103c4d3d7 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -40,7 +40,8 @@ module Types field :status, AlertManagement::StatusEnum, null: true, - description: 'Status of the alert' + description: 'Status of the alert', + method: :status_name field :service, GraphQL::STRING_TYPE, diff --git a/app/graphql/types/alert_management/status_enum.rb b/app/graphql/types/alert_management/status_enum.rb index 4ff6c4a9505..9d2c7316254 100644 --- a/app/graphql/types/alert_management/status_enum.rb +++ b/app/graphql/types/alert_management/status_enum.rb @@ -6,8 +6,8 @@ module Types graphql_name 'AlertManagementStatus' description 'Alert status values' - ::AlertManagement::Alert::STATUSES.each do |name, value| - value name.upcase, value: value, description: "#{name.to_s.titleize} status" + ::AlertManagement::Alert.status_names.each do |status| + value status.to_s.upcase, value: status, description: "#{status.to_s.titleize} status" end end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 2eff87ae0ec..d82e9573272 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true module BlobHelper - def highlight(file_name, file_content, language: nil, plain: false) - highlighted = Gitlab::Highlight.highlight(file_name, file_content, plain: plain, language: language) - - raw %(<pre class="code highlight"><code>#{highlighted}</code></pre>) - end - def no_highlight_files %w(credits changelog news copying copyright license authors) end @@ -148,7 +142,7 @@ module BlobHelper # mode - File unix mode # mode - File name def blob_icon(mode, name) - icon("#{file_type_icon_class('file', mode, name)} fw") + sprite_icon(file_type_icon_class('file', mode, name)) end def blob_raw_url(**kwargs) diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 8cdb28b2874..552acf61f47 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -39,6 +39,14 @@ module Ci runner.contacted_at end end + + def group_shared_runners_settings_data(group) + { + update_path: api_v4_groups_path(id: group.id), + shared_runners_availability: group.shared_runners_setting, + parent_shared_runners_availability: group.parent&.shared_runners_setting + } + end end end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 0352b0ddf28..1d0001fde72 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -124,7 +124,7 @@ module IconsHelper def file_type_icon_class(type, mode, name) if type == 'folder' - icon_class = 'folder' + icon_class = 'folder-o' elsif type == 'archive' icon_class = 'archive' elsif mode == '120000' @@ -135,36 +135,36 @@ module IconsHelper case File.extname(name).downcase when '.pdf' - icon_class = 'file-pdf-o' + icon_class = 'document' when '.jpg', '.jpeg', '.jif', '.jfif', '.jp2', '.jpx', '.j2k', '.j2c', '.apng', '.png', '.gif', '.tif', '.tiff', '.svg', '.ico', '.bmp', '.webp' - icon_class = 'file-image-o' + icon_class = 'doc-image' when '.zip', '.zipx', '.tar', '.gz', '.gzip', '.tgz', '.bz', '.bzip', '.bz2', '.bzip2', '.car', '.tbz', '.xz', 'txz', '.rar', '.7z', '.lz', '.lzma', '.tlz' - icon_class = 'file-archive-o' + icon_class = 'doc-compressed' when '.mp3', '.wma', '.ogg', '.oga', '.wav', '.flac', '.aac', '.3ga', '.ac3', '.midi', '.m4a', '.ape', '.mpa' - icon_class = 'file-audio-o' + icon_class = 'volume-up' when '.mp4', '.m4p', '.m4v', '.mpg', '.mp2', '.mpeg', '.mpe', '.mpv', '.mpg', '.mpeg', '.m2v', '.m2ts', '.avi', '.mkv', '.flv', '.ogv', '.mov', '.3gp', '.3g2' - icon_class = 'file-video-o' + icon_class = 'live-preview' when '.doc', '.dot', '.docx', '.docm', '.dotx', '.dotm', '.docb', '.odt', '.ott', '.uot', '.rtf' - icon_class = 'file-word-o' + icon_class = 'doc-text' when '.xls', '.xlt', '.xlm', '.xlsx', '.xlsm', '.xltx', '.xltm', '.xlsb', '.xla', '.xlam', '.xll', '.xlw', '.ots', '.ods', '.uos' - icon_class = 'file-excel-o' + icon_class = 'document' when '.ppt', '.pot', '.pps', '.pptx', '.pptm', '.potx', '.potm', '.ppam', '.ppsx', '.ppsm', '.sldx', '.sldm', '.odp', '.otp', '.uop' - icon_class = 'file-powerpoint-o' + icon_class = 'doc-chart' else - icon_class = 'file-text-o' + icon_class = 'doc-text' end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index e0dbd33b5d2..63601485daf 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -301,8 +301,21 @@ module SearchHelper sanitize(html, tags: %w(a p ol ul li pre code)) end + def simple_search_highlight_and_truncate(text, phrase, options = {}) + text = Truncato.truncate( + text, + count_tags: false, + count_tail: false, + max_length: options.delete(:length) { 200 } + ) + + highlight(text, phrase.split, options) + end + # _search_highlight is used in EE override def highlight_and_truncate_issue(issue, search_term, _search_highlight) + return unless issue.description.present? + simple_search_highlight_and_truncate(issue.description, search_term, highlighter: '<span class="gl-text-black-normal gl-font-weight-bold">\1</span>') end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 1d8d9ddc1ec..1510599cd11 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -31,7 +31,7 @@ module TreeHelper # mode - File unix mode # name - File name def tree_icon(type, mode, name) - icon([file_type_icon_class(type, mode, name), 'fw']) + sprite_icon(file_type_icon_class(type, mode, name)) end # Using Rails `*_path` methods can be slow, especially when generating diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index 5f69020ae76..44d82453c1f 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -20,6 +20,7 @@ module AlertManagement resolved: 2, ignored: 3 }.freeze + private_constant :STATUSES belongs_to :project belongs_to :issue, optional: true @@ -109,7 +110,7 @@ module AlertManagement delegate :details_url, to: :present scope :for_iid, -> (iid) { where(iid: iid) } - scope :for_status, -> (status) { where(status: status) } + scope :for_status, -> (status) { with_status(status) } scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) } scope :for_environment, -> (environment) { where(environment: environment) } scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) } @@ -130,13 +131,33 @@ module AlertManagement # Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered # Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior - scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) } + scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) } - scope :counts_by_status, -> { group(:status).count } scope :counts_by_project_id, -> { group(:project_id).count } alias_method :state, :status_name + def self.state_machine_statuses + @state_machine_statuses ||= state_machines[:status].states.to_h { |s| [s.name, s.value] } + end + private_class_method :state_machine_statuses + + def self.status_value(name) + state_machine_statuses[name] + end + + def self.status_name(raw_status) + state_machine_statuses.key(raw_status) + end + + def self.counts_by_status + group(:status).count.transform_keys { |k| status_name(k) } + end + + def self.status_names + @status_names ||= state_machine_statuses.keys + end + def self.sort_by_attribute(method) case method.to_s when 'started_at_asc' then order_start_time(:asc) diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb index 9cd4c554b8c..464d5f2ecea 100644 --- a/app/services/alert_management/alerts/update_service.rb +++ b/app/services/alert_management/alerts/update_service.rb @@ -13,6 +13,7 @@ module AlertManagement @current_user = current_user @params = params @param_errors = [] + @status = params.delete(:status) end def execute @@ -35,7 +36,7 @@ module AlertManagement private - attr_reader :alert, :current_user, :params, :param_errors + attr_reader :alert, :current_user, :params, :param_errors, :status delegate :resolved?, to: :alert def allowed? @@ -68,8 +69,12 @@ module AlertManagement param_errors << message end + def param_errors? + params.empty? && status.blank? + end + def filter_params - param_errors << _('Please provide attributes to update') if params.empty? + param_errors << _('Please provide attributes to update') if param_errors? filter_status filter_assignees @@ -110,9 +115,9 @@ module AlertManagement # ------ Status-related behavior ------- def filter_status - return unless params[:status] + return unless status - status_event = alert.status_event_for(status_key) + status_event = alert.status_event_for(status) unless status_event param_errors << _('Invalid status') @@ -122,13 +127,6 @@ module AlertManagement params[:status_event] = status_event end - def status_key - strong_memoize(:status_key) do - status = params.delete(:status) - AlertManagement::Alert::STATUSES.key(status) - end - end - def handle_status_change add_status_change_system_note resolve_todos if resolved? @@ -144,7 +142,7 @@ module AlertManagement def filter_duplicate # Only need to check if changing to an open status - return unless params[:status_event] && AlertManagement::Alert.open_status?(status_key) + return unless params[:status_event] && AlertManagement::Alert.open_status?(status) param_errors << unresolved_alert_error if duplicate_alert? end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 278cf389e07..3ccd67c8d30 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -65,6 +65,10 @@ class SearchService @search_objects ||= redact_unauthorized_results(search_results.objects(scope, page: params[:page], per_page: per_page, preload_method: preload_method)) end + def search_highlight + search_results.highlight_map(scope) + end + private def per_page diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 8d379774719..b90e672cca9 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -24,7 +24,7 @@ - else .row .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint, maskable_regex: ci_variable_maskable_regex } } - .hide.alert.alert-danger.js-ci-variable-error-box + .hide.gl-alert.gl-alert-danger.js-ci-variable-error-box %ul.ci-variable-list = render 'ci/variables/variable_header' diff --git a/app/views/groups/runners/_index.html.haml b/app/views/groups/runners/_index.html.haml index e885fcc08eb..b342b589d93 100644 --- a/app/views/groups/runners/_index.html.haml +++ b/app/views/groups/runners/_index.html.haml @@ -7,6 +7,8 @@ .row .col-sm-6 = render 'groups/runners/group_runners' + .col-sm-6 + = render 'groups/runners/shared_runners' %h4.underlined-title = _('Available Runners: %{runners}').html_safe % { runners: limited_counter_with_delimiter(@all_group_runners) } diff --git a/app/views/groups/runners/_shared_runners.html.haml b/app/views/groups/runners/_shared_runners.html.haml new file mode 100644 index 00000000000..15b1199b8c9 --- /dev/null +++ b/app/views/groups/runners/_shared_runners.html.haml @@ -0,0 +1,3 @@ += render 'shared/runners/shared_runners_description' + +#update-shared-runners-form{ data: group_shared_runners_settings_data(@group) } diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index 8a17ca3c670..c567b453bf2 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -1,19 +1,16 @@ -%h3 - = _('Shared Runners') - -.bs-callout.shared-runners-description - - if Gitlab::CurrentSettings.shared_runners_text.present? - = markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text) - - else - = _('GitLab Shared Runners execute code of different projects on the same Runner unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is on GitLab.com).') += render layout: 'shared/runners/shared_runners_description' do %hr - - if @project.shared_runners_enabled? - = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do - = _('Disable shared Runners') + - if @project.group&.shared_runners_setting == 'disabled_and_unoverridable' + %h5.gl-text-red-500 + = _('Shared runners disabled on group level') - else - = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do - = _('Enable shared Runners') - for this project + - if @project.shared_runners_enabled? + = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do + = _('Disable shared runners') + - else + = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do + = _('Enable shared runners') + for this project - if @shared_runners_count == 0 = _('This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area.') diff --git a/app/views/projects/settings/ci_cd/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml index 82c8ec088e5..2c3e6387972 100644 --- a/app/views/projects/settings/ci_cd/_badge.html.haml +++ b/app/views/projects/settings/ci_cd/_badge.html.haml @@ -15,18 +15,18 @@ .col-md-2.text-center Markdown .col-md-10.code.js-syntax-highlight - = highlight('.md', badge.to_markdown, language: 'markdown') + = highlight_badge('.md', badge.to_markdown, language: 'markdown') .row %hr .row .col-md-2.text-center HTML .col-md-10.code.js-syntax-highlight - = highlight('.html', badge.to_html, language: 'html') + = highlight_badge('.html', badge.to_html, language: 'html') .row %hr .row .col-md-2.text-center AsciiDoc .col-md-10.code.js-syntax-highlight - = highlight('.adoc', badge.to_asciidoc) + = highlight_badge('.adoc', badge.to_asciidoc) diff --git a/app/views/projects/tracings/_tracing_button.html.haml b/app/views/projects/tracings/_tracing_button.html.haml new file mode 100644 index 00000000000..c9a6afd3761 --- /dev/null +++ b/app/views/projects/tracings/_tracing_button.html.haml @@ -0,0 +1,2 @@ += link_to project_settings_operations_path(@project), title: _('Configure Tracing'), class: 'btn btn-success' do + = _('Add Jaeger URL') diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml new file mode 100644 index 00000000000..8c9bffc81bf --- /dev/null +++ b/app/views/projects/tracings/show.html.haml @@ -0,0 +1,33 @@ +- @content_class = "limit-container-width" unless fluid_layout +- page_title _("Tracing") + +- if @project.tracing_external_url.present? + %h3.page-title= _('Tracing') + .gl-alert.gl-alert-info.alert.flex-alert + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .alert-message + = _("Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse.") + - jaeger_link = link_to('Jaeger tracing', 'https://www.jaegertracing.io/', target: "_blank", rel: "noreferrer") + %p.light= _("GitLab uses %{jaeger_link} to monitor distributed systems.").html_safe % { jaeger_link: jaeger_link } + + + .card + - iframe_permissions = "allow-forms allow-scripts allow-same-origin allow-popups" + %iframe.border-0{ src: sanitize(@project.tracing_external_url, scrubber: Rails::Html::TextOnlyScrubber.new), width: '100%', height: 970, sandbox: iframe_permissions } +- else + .row.empty-state + .col-12 + .svg-content + = image_tag 'illustrations/monitoring/tracing.svg' + + .col-12 + .text-content + %h4.text-left= _('Troubleshoot and monitor your application with tracing') + %p + - jaeger_help_url = "https://www.jaegertracing.io/docs/1.7/getting-started/" + - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url } + - link_end_tag = "#{sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')}</a>".html_safe + = _('To get started, link this page to your Jaeger server, or find out how to %{link_start_tag}install Jaeger%{link_end_tag}').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag } + + .text-center + = render 'tracing_button' diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index e0336d98f04..a101e60f297 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -9,6 +9,5 @@ %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issue.title .gl-text-gray-500.gl-my-3 = sprintf(s_(' %{project_name}#%{issue_iid} · opened %{issue_created} by %{author}'), { project_name: issue.project.full_name, issue_iid: issue.iid, issue_created: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), author: link_to_member(@project, issue.author, avatar: false) }).html_safe - - if issue.description.present? - .description.term.col-sm-10.gl-px-0 - = truncate(issue.description, length: 200) + .description.term.col-sm-10.gl-px-0 + = highlight_and_truncate_issue(issue, @search_term, @search_highlight) diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 4d209c30e7b..c37fdf0c98f 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -7,7 +7,7 @@ = render 'shared/milestones/description', milestone: milestone - if milestone.complete? && milestone.active? - .alert.alert-success.gl-mt-3 + .gl-alert.gl-alert-success.gl-mt-3 %span = _('All issues for this milestone are closed.') = group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.') diff --git a/app/views/shared/runners/_shared_runners_description.html.haml b/app/views/shared/runners/_shared_runners_description.html.haml new file mode 100644 index 00000000000..b9fb518b1aa --- /dev/null +++ b/app/views/shared/runners/_shared_runners_description.html.haml @@ -0,0 +1,11 @@ +- link = link_to _('MaxBuilds'), 'https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnersmachine-section', target: '_blank' + +%h3 + = _('Shared runners') + +.bs-callout.shared-runners-description + - if Gitlab::CurrentSettings.shared_runners_text.present? + = markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text) + - else + = _('The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com).').html_safe % { link: link } + = yield diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index f1733ce2b51..5e212721734 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -67,15 +67,15 @@ - unless @user.skype.blank? .profile-link-holder.middle-dot-divider = link_to "skype:#{@user.skype}", title: "Skype" do - = icon('skype') + = sprite_icon('skype') - unless @user.linkedin.blank? .profile-link-holder.middle-dot-divider = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do - = icon('linkedin-square') + = sprite_icon('linkedin') - unless @user.twitter.blank? .profile-link-holder.middle-dot-divider-sm = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do - = icon('twitter-square') + = sprite_icon('twitter') - unless @user.website_url.blank? .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0 = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow' diff --git a/changelogs/unreleased/216861-ui-for-mr-title-description.yml b/changelogs/unreleased/216861-ui-for-mr-title-description.yml new file mode 100644 index 00000000000..0fb500f1fdb --- /dev/null +++ b/changelogs/unreleased/216861-ui-for-mr-title-description.yml @@ -0,0 +1,5 @@ +--- +title: Add merge request title and description UI to Static Site Editor submission flow +merge_request: 44071 +author: +type: added diff --git a/changelogs/unreleased/244427-bold-search-term-issues.yml b/changelogs/unreleased/244427-bold-search-term-issues.yml new file mode 100644 index 00000000000..5e960541990 --- /dev/null +++ b/changelogs/unreleased/244427-bold-search-term-issues.yml @@ -0,0 +1,5 @@ +--- +title: Global Search - Bold Issue's Search Term +merge_request: 43124 +author: +type: changed diff --git a/changelogs/unreleased/246739-use-gitlab-svg-icons-in-file_type_icon_class-helper.yml b/changelogs/unreleased/246739-use-gitlab-svg-icons-in-file_type_icon_class-helper.yml new file mode 100644 index 00000000000..0c007f8a864 --- /dev/null +++ b/changelogs/unreleased/246739-use-gitlab-svg-icons-in-file_type_icon_class-helper.yml @@ -0,0 +1,5 @@ +--- +title: Use GitLab SVG icons in file_type_icon_class helper +merge_request: 44580 +author: +type: changed diff --git a/changelogs/unreleased/263237-replace-font-awesome-icons-in-user-profile.yml b/changelogs/unreleased/263237-replace-font-awesome-icons-in-user-profile.yml new file mode 100644 index 00000000000..1a54c38161c --- /dev/null +++ b/changelogs/unreleased/263237-replace-font-awesome-icons-in-user-profile.yml @@ -0,0 +1,5 @@ +--- +title: Replace Font Awesome social icons with GitLab SVGs on user profile page +merge_request: 44599 +author: +type: other diff --git a/changelogs/unreleased/disable-shared-runners-ui.yml b/changelogs/unreleased/disable-shared-runners-ui.yml new file mode 100644 index 00000000000..a36213f1448 --- /dev/null +++ b/changelogs/unreleased/disable-shared-runners-ui.yml @@ -0,0 +1,5 @@ +--- +title: UI to disable shared runners by group +merge_request: 39249 +author: +type: added diff --git a/changelogs/unreleased/replace-alert-milestones.yml b/changelogs/unreleased/replace-alert-milestones.yml new file mode 100644 index 00000000000..f90863a209a --- /dev/null +++ b/changelogs/unreleased/replace-alert-milestones.yml @@ -0,0 +1,5 @@ +--- +title: Replace bootstrap alert in app/views/shared/milestones/_top.html.haml +merge_request: 44731 +author: +type: changed diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index 5e5fc5f9af7..297254364ac 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -45,6 +45,7 @@ en: signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + signed_up_but_blocked_pending_approval: "You have signed up successfully. However, we could not sign you in because your account is awaiting approval from your administrator." update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address." updated: "Your account has been updated successfully." sessions: diff --git a/config/routes/project.rb b/config/routes/project.rb index 2c681b3cbe7..1072a037823 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -307,6 +307,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get 'details', on: :member end + resource :tracing, only: [:show] + post 'incidents/integrations/pagerduty', to: 'incident_management/pager_duty_incidents#create' resources :incidents, only: [:index] @@ -551,6 +553,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do Gitlab::Routing.redirect_legacy_paths(self, :mirror, :tags, :cycle_analytics, :mattermost, :variables, :triggers, :environments, :protected_environments, :error_tracking, :alert_management, + :tracing, :serverless, :clusters, :audit_events, :wikis, :merge_requests, :vulnerability_feedback, :security, :dependencies, :issues) end diff --git a/doc/.vale/gitlab/SubstitutionWarning.yml b/doc/.vale/gitlab/SubstitutionWarning.yml index 394bd5df08d..68313a37e7d 100644 --- a/doc/.vale/gitlab/SubstitutionWarning.yml +++ b/doc/.vale/gitlab/SubstitutionWarning.yml @@ -11,7 +11,6 @@ link: https://about.gitlab.com/handbook/communication/#top-misused-terms level: warning ignorecase: true swap: - blacklist(ed|ing)?: denylist code base: codebase config: configuration distro: distribution diff --git a/doc/api/groups.md b/doc/api/groups.md index a74bb64816a..462972206d9 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -759,6 +759,7 @@ Parameters: | `default_branch_protection` | integer | no | See [Options for `default_branch_protection`](#options-for-default_branch_protection). Default to the global level default branch protection setting. | | `shared_runners_minutes_limit` | integer | no | **(STARTER ONLY)** Pipeline minutes quota for this group (included in plan). Can be `nil` (default; inherit system default), `0` (unlimited) or `> 0` | | `extra_shared_runners_minutes_limit` | integer | no | **(STARTER ONLY)** Extra pipeline minutes quota for this group (purchased in addition to the minutes included in the plan). | +| `shared_runners_setting` | string | no | See [Options for `shared_runners_setting`](#options-for-shared_runners_setting). Enable or disable shared runners for a group's subgroups and projects. | ### Options for `default_branch_protection` @@ -770,6 +771,16 @@ The `default_branch_protection` attribute determines whether developers and main | `1` | Partial protection. Developers and maintainers can: <br>- Push new commits | | `2` | Full protection. Only maintainers can: <br>- Push new commits | +### Options for `shared_runners_setting` + +The `shared_runners_setting` attribute determines whether shared runners are enabled for a group's subgroups and projects. + +| Value | Description | +|-------|-------------------------------------------------------------------------------------------------------------| +| `enabled` | Enables shared runners for all projects and subgroups in this group. | +| `disabled_with_override` | Disables shared runners for all projects and subgroups in this group, but allows subgroups to override this setting. | +| `disabled_and_unoverridable` | Disables shared runners for all projects and subgroups in this group, and prevents subgroups from overriding this setting. | + ## New Subgroup This is similar to creating a [New group](#new-group). You'll need the `parent_id` from the [List groups](#list-groups) call. You can then enter the desired: diff --git a/doc/development/changelog.md b/doc/development/changelog.md index d992f394de3..fddce629cd7 100644 --- a/doc/development/changelog.md +++ b/doc/development/changelog.md @@ -31,7 +31,10 @@ the `author` field. GitLab team members **should not**. - Any change that introduces a database migration, whether it's regular, post, or data migration, **must** have a changelog entry, even if it is behind a - disabled feature flag. + disabled feature flag. Since the migration is executed on [GitLab FOSS](https://gitlab.com/gitlab-org/gitlab-foss/), + the changelog for database schema changes should be written to the + `changelogs/unreleased/` directory, even when other elements of that change affect only GitLab EE. + - [Security fixes](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md) **must** have a changelog entry, without `merge_request` value and with `type` set to `security`. @@ -45,6 +48,7 @@ the `author` field. GitLab team members **should not**. - Any docs-only changes **should not** have a changelog entry. - Any change behind a disabled feature flag **should not** have a changelog entry. - Any change behind an enabled feature flag **should** have a changelog entry. +- Any change that adds new usage data metrics and changes that needs to be documented in Product Analytics [Event Dictionary](telemetry/event_dictionary.md) **should** have a changelog entry. - A change that [removes a feature flag](feature_flags/development.md) **should** have a changelog entry - only if the feature flag did not default to true already. - A fix for a regression introduced and then fixed in the same release (i.e., diff --git a/doc/development/feature_flags/controls.md b/doc/development/feature_flags/controls.md index 605b5919e0b..b759fb58fb8 100644 --- a/doc/development/feature_flags/controls.md +++ b/doc/development/feature_flags/controls.md @@ -88,15 +88,13 @@ parts of the company. The developer responsible needs to determine whether this is necessary and the appropriate level of communication. This depends on the feature and what sort of impact it might have. -As a guideline: +Guidelines: -- For simple features that are low-risk, and easily rolled back, then - just proceed to [enabling the feature in `#production`](#process). -- For features that will impact user experience consider notifying +1. If the feature meets the requirements for creating a [Change Management](https://about.gitlab.com/handbook/engineering/infrastructure/change-management/#feature-flags-and-the-change-management-process) issue, create a Change Management issue per [criticality guidelines](https://about.gitlab.com/handbook/engineering/infrastructure/change-management/#change-request-workflows). +1. For simple, low-risk, easily reverted features, proceed and [enable the feature in `#production`](#process). +1. For features that impact the user experience, consider notifying + `#support_gitlab-com` first. `#support_gitlab-com` beforehand. -- For features with significant downstream effects (e.g.: turning on/off - Elasticsearch indexing) consider coordinating with `#production` - beforehand. #### Process diff --git a/doc/user/group/epics/index.md b/doc/user/group/epics/index.md index 60e10e1f55c..d46266afc5f 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -65,7 +65,12 @@ graph TD ``` See [Manage issues assigned to an epic](manage_epics.md#manage-issues-assigned-to-an-epic) for steps -to add an issue to an epic, reorder issues, move issues between epics, or promote an issue to an epic. +to: + +- Add an issue to an epic +- Reorder issues +- Move an issue between epics +- Promote an issue to an epic ## Issue health status in Epic tree **(ULTIMATE)** diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index a19b819c823..ebec327532d 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -58,6 +58,43 @@ CPU and Memory consumption is monitored, but requires [naming conventions](prome The [NGINX Ingress](../clusters/index.md#installing-applications) that is deployed by GitLab to clusters, is automatically annotated for monitoring providing key response metrics: latency, throughput, and error rates. +##### Example of Kubernetes service annotations and labels + +As an example, to activate Prometheus monitoring of a service: + +1. Add at least this annotation: `prometheus.io/scrape: 'true'`. +1. Add two labels so GitLab can retrieve metrics dynamically for any environment: + - `application: ${CI_ENVIRONMENT_SLUG}` + - `release: ${CI_ENVIRONMENT_SLUG}` +1. Create a dynamic PromQL query. For example, a query like + `temperature{application="{{ci_environment_slug}}",release="{{ci_environment_slug}}"}` to either: + - Add [custom metrics](../../../operations/metrics/index.md#adding-custom-metrics). + - Add [custom dashboards](../../../operations/metrics/dashboards/index.md). + +The following is a service definition to accomplish this: + +```yaml +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: service-${CI_PROJECT_NAME}-${CI_COMMIT_REF_SLUG} + # === Prometheus annotations === + annotations: + prometheus.io/scrape: 'true' + labels: + application: ${CI_ENVIRONMENT_SLUG} + release: ${CI_ENVIRONMENT_SLUG} + # === End of Prometheus === +spec: + selector: + app: ${CI_PROJECT_NAME} + ports: + - port: ${EXPOSED_PORT} + targetPort: ${CONTAINER_PORT} +``` + ### Manual configuration of Prometheus #### Requirements diff --git a/lib/api/helpers/groups_helpers.rb b/lib/api/helpers/groups_helpers.rb index f3dfc093926..ba07a70ee32 100644 --- a/lib/api/helpers/groups_helpers.rb +++ b/lib/api/helpers/groups_helpers.rb @@ -24,6 +24,7 @@ module API optional :lfs_enabled, type: Boolean, desc: 'Enable/disable LFS for the projects in this group' optional :request_access_enabled, type: Boolean, desc: 'Allow users to request member access' optional :default_branch_protection, type: Integer, values: ::Gitlab::Access.protection_values, desc: 'Determine if developers can push to master' + optional :shared_runners_setting, type: String, values: ::Namespace::SHARED_RUNNERS_SETTINGS, desc: 'Enable/disable shared runners for the group and its subgroups and projects' end params :optional_params_ee do diff --git a/lib/gitlab/alert_management/alert_status_counts.rb b/lib/gitlab/alert_management/alert_status_counts.rb index e88436d479b..e55e0016599 100644 --- a/lib/gitlab/alert_management/alert_status_counts.rb +++ b/lib/gitlab/alert_management/alert_status_counts.rb @@ -6,8 +6,6 @@ module Gitlab class AlertStatusCounts include Gitlab::Utils::StrongMemoize - STATUSES = ::AlertManagement::Alert::STATUSES - attr_reader :project def self.declarative_policy_class @@ -21,7 +19,7 @@ module Gitlab end # Define method for each status - STATUSES.each_key do |status| + ::AlertManagement::Alert.status_names.each do |status| define_method(status) { counts[status] } end @@ -44,9 +42,7 @@ module Gitlab end def counts_by_status - ::AlertManagement::AlertsFinder - .counts_by_status(current_user, project, params) - .transform_keys { |status_id| STATUSES.key(status_id) } + ::AlertManagement::AlertsFinder.counts_by_status(current_user, project, params) end end end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 1e78464ddf8..3d4920456e2 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -108,6 +108,11 @@ module Gitlab UsersFinder.new(current_user, search: query).execute end + # highlighting is only performed by Elasticsearch backed results + def highlight_map(scope) + {} + end + private def collection_for(scope) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b396bd62691..3c9785b31e8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2611,6 +2611,9 @@ msgstr "" msgid "Allow owners to manually add users outside of LDAP" msgstr "" +msgid "Allow projects and subgroups to override the group setting" +msgstr "" + msgid "Allow projects within this group to use Git LFS" msgstr "" @@ -2659,6 +2662,9 @@ msgstr "" msgid "Allowed to fail" msgstr "" +msgid "Allows projects or subgroups in this group to override the global setting." +msgstr "" + msgid "Allows you to add and manage Kubernetes clusters." msgstr "" @@ -3022,6 +3028,9 @@ msgstr "" msgid "An error occurred while updating approvers" msgstr "" +msgid "An error occurred while updating configuration." +msgstr "" + msgid "An error occurred while updating labels." msgstr "" @@ -4188,6 +4197,9 @@ msgstr "" msgid "Boards|View scope" msgstr "" +msgid "Board|Load more issues" +msgstr "" + msgid "Both project and dashboard_path are required" msgstr "" @@ -4344,6 +4356,9 @@ msgstr "" msgid "Branches|protected" msgstr "" +msgid "Brief title about the change" +msgstr "" + msgid "Broadcast Message was successfully created." msgstr "" @@ -9114,7 +9129,7 @@ msgstr "" msgid "Disable public access to Pages sites" msgstr "" -msgid "Disable shared Runners" +msgid "Disable shared runners" msgstr "" msgid "Disable two-factor authentication" @@ -9740,7 +9755,13 @@ msgstr "" msgid "Enable reCAPTCHA or Akismet and set IP limits. For reCAPTCHA, we currently only support %{recaptcha_v2_link_start}v2%{recaptcha_v2_link_end}" msgstr "" -msgid "Enable shared Runners" +msgid "Enable shared runners" +msgstr "" + +msgid "Enable shared runners for all projects and subgroups in this group." +msgstr "" + +msgid "Enable shared runners for this group" msgstr "" msgid "Enable snowplow tracking" @@ -12088,9 +12109,6 @@ msgstr "" msgid "GitLab Service Desk is a simple way to allow people to create issues in your GitLab instance without needing their own user account. It provides a unique email address for end users to create issues in a project, and replies can be sent either through the GitLab interface or by email. End users will only see the thread through email." msgstr "" -msgid "GitLab Shared Runners execute code of different projects on the same Runner unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is on GitLab.com)." -msgstr "" - msgid "GitLab Shell" msgstr "" @@ -12427,6 +12445,9 @@ msgstr "" msgid "Go to your snippets" msgstr "" +msgid "Goal of the changes and what reviewers should be aware of" +msgstr "" + msgid "Google Cloud Platform" msgstr "" @@ -14003,9 +14024,6 @@ msgstr "" msgid "Invalid URL" msgstr "" -msgid "Invalid board" -msgstr "" - msgid "Invalid container_name" msgstr "" @@ -15780,6 +15798,9 @@ msgstr "" msgid "Max size 15 MB" msgstr "" +msgid "MaxBuilds" +msgstr "" + msgid "Maximum Conan package file size in bytes" msgstr "" @@ -21293,6 +21314,9 @@ msgstr "" msgid "Refresh" msgstr "" +msgid "Refresh the page and try again." +msgstr "" + msgid "Refreshing in a second to show the updated status..." msgid_plural "Refreshing in %d seconds to show the updated status..." msgstr[0] "" @@ -23632,6 +23656,9 @@ msgstr "" msgid "Set up pipeline subscriptions for this project." msgstr "" +msgid "Set up shared runner availability" +msgstr "" + msgid "Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically." msgstr "" @@ -23737,6 +23764,15 @@ msgstr "" msgid "Shared projects" msgstr "" +msgid "Shared runners" +msgstr "" + +msgid "Shared runners are disabled for the parent group" +msgstr "" + +msgid "Shared runners disabled on group level" +msgstr "" + msgid "Shared runners help link" msgstr "" @@ -24875,6 +24911,9 @@ msgstr "" msgid "Submit changes" msgstr "" +msgid "Submit changes..." +msgstr "" + msgid "Submit feedback" msgstr "" @@ -24890,6 +24929,9 @@ msgstr "" msgid "Submit the current review." msgstr "" +msgid "Submit your changes" +msgstr "" + msgid "Submitted the current review." msgstr "" @@ -25866,6 +25908,9 @@ msgstr "" msgid "The roadmap shows the progress of your epics along a timeline" msgstr "" +msgid "The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com)." +msgstr "" + msgid "The schedule time must be in the future!" msgstr "" diff --git a/rubocop/cop/rspec/timecop_travel.rb b/rubocop/cop/rspec/timecop_travel.rb new file mode 100644 index 00000000000..e5416953af7 --- /dev/null +++ b/rubocop/cop/rspec/timecop_travel.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module RSpec + # This cop checks for `Timecop.travel` usage in specs. + # + # @example + # + # # bad + # Timecop.travel(1.day.ago) { create(:issue) } + # + # # good + # travel_to(1.day.ago) { create(:issue) } + # + class TimecopTravel < RuboCop::Cop::Cop + include MatchRange + MESSAGE = 'Do not use `Timecop.travel`, use `travel_to` instead. ' \ + 'See https://gitlab.com/gitlab-org/gitlab/-/issues/214432 for more info.' + + def_node_matcher :timecop_travel?, <<~PATTERN + (send (const nil? :Timecop) :travel _) + PATTERN + + def on_send(node) + return unless timecop_travel?(node) + + add_offense(node, location: :expression, message: MESSAGE) + end + + def autocorrect(node) + -> (corrector) do + each_match_range(node.source_range, /^(Timecop\.travel)/) do |match_range| + corrector.replace(match_range, 'travel_to') + end + end + end + end + end + end +end diff --git a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb index 5f636bd4340..c2cc3d10ea0 100644 --- a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb @@ -156,7 +156,7 @@ RSpec.describe Projects::MergeRequests::ConflictsController do expect(json_response).to include('old_path' => path, 'new_path' => path, - 'blob_icon' => 'file-text-o', + 'blob_icon' => 'doc-text', 'blob_path' => a_string_ending_with(path), 'content' => content) end diff --git a/spec/controllers/projects/tracings_controller_spec.rb b/spec/controllers/projects/tracings_controller_spec.rb new file mode 100644 index 00000000000..1877822df54 --- /dev/null +++ b/spec/controllers/projects/tracings_controller_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::TracingsController do + let_it_be(:user) { create(:user) } + + describe 'GET show' do + shared_examples 'user with read access' do |visibility_level| + let(:project) { create(:project, visibility_level) } + + %w[developer maintainer].each do |role| + context "with a #{visibility_level} project and #{role} role" do + before do + project.add_role(user, role) + end + + it 'renders OK' do + get :show, params: { namespace_id: project.namespace, project_id: project } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:show) + end + end + end + end + + shared_examples 'user without read access' do |visibility_level| + let(:project) { create(:project, visibility_level) } + + %w[guest reporter].each do |role| + context "with a #{visibility_level} project and #{role} role" do + before do + project.add_role(user, role) + end + + it 'returns 404' do + get :show, params: { namespace_id: project.namespace, project_id: project } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + end + + describe 'with valid license' do + before do + stub_licensed_features(tracing: true) + sign_in(user) + end + + context 'with maintainer role' do + it_behaves_like 'user with read access', :public + it_behaves_like 'user with read access', :internal + it_behaves_like 'user with read access', :private + end + + context 'without maintainer role' do + it_behaves_like 'user without read access', :public + it_behaves_like 'user without read access', :internal + it_behaves_like 'user without read access', :private + end + end + + context 'with invalid license' do + before do + stub_licensed_features(tracing: false) + sign_in(user) + end + + it_behaves_like 'user without read access', :public + it_behaves_like 'user without read access', :internal + it_behaves_like 'user without read access', :private + end + end +end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index a3f80ca67d5..c3080b50f44 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -83,19 +83,117 @@ RSpec.describe RegistrationsController do let(:base_user_params) { { first_name: 'first', last_name: 'last', username: 'new_username', email: 'new@user.com', password: 'Any_password' } } let(:user_params) { { user: base_user_params } } - context 'email confirmation' do - around do |example| - perform_enqueued_jobs do - example.run + subject { post(:create, params: user_params) } + + context '`blocked_pending_approval` state' do + context 'when the feature is enabled' do + before do + stub_feature_flags(admin_approval_for_new_user_signups: true) + end + + context 'when the `require_admin_approval_after_user_signup` setting is turned on' do + before do + stub_application_setting(require_admin_approval_after_user_signup: true) + end + + it 'signs up the user in `blocked_pending_approval` state' do + subject + created_user = User.find_by(email: 'new@user.com') + + expect(created_user).to be_present + expect(created_user.blocked_pending_approval?).to eq(true) + end + + it 'does not log in the user after sign up' do + subject + + expect(controller.current_user).to be_nil + end + + it 'shows flash message after signing up' do + subject + + expect(response).to redirect_to(new_user_session_path(anchor: 'login-pane')) + expect(flash[:notice]) + .to eq('You have signed up successfully. However, we could not sign you in because your account is awaiting approval from your administrator.') + end + + context 'email confirmation' do + context 'when `send_user_confirmation_email` is true' do + before do + stub_application_setting(send_user_confirmation_email: true) + end + + it 'does not send a confirmation email' do + expect { subject } + .not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions) + end + end + end + end + + context 'when the `require_admin_approval_after_user_signup` setting is turned off' do + before do + stub_application_setting(require_admin_approval_after_user_signup: false) + end + + it 'signs up the user in `active` state' do + subject + created_user = User.find_by(email: 'new@user.com') + + expect(created_user).to be_present + expect(created_user.active?).to eq(true) + end + + it 'does not show any flash message after signing up' do + subject + + expect(flash[:notice]).to be_nil + end + + context 'email confirmation' do + context 'when `send_user_confirmation_email` is true' do + before do + stub_application_setting(send_user_confirmation_email: true) + end + + it 'sends a confirmation email' do + expect { subject } + .to have_enqueued_mail(DeviseMailer, :confirmation_instructions) + end + end + end + end + end + + context 'when the feature is disabled' do + before do + stub_feature_flags(admin_approval_for_new_user_signups: false) + end + + context 'when the `require_admin_approval_after_user_signup` setting is turned on' do + before do + stub_application_setting(require_admin_approval_after_user_signup: true) + end + + it 'signs up the user in `active` state' do + subject + + created_user = User.find_by(email: 'new@user.com') + expect(created_user).to be_present + expect(created_user.active?).to eq(true) + end end end + end + context 'email confirmation' do context 'when send_user_confirmation_email is false' do it 'signs the user in' do stub_application_setting(send_user_confirmation_email: false) - expect { post(:create, params: user_params) }.not_to change { ActionMailer::Base.deliveries.size } - expect(subject.current_user).not_to be_nil + expect { subject }.not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions) + expect(controller.current_user).not_to be_nil end end @@ -111,10 +209,8 @@ RSpec.describe RegistrationsController do end it 'does not authenticate the user and sends a confirmation email' do - post(:create, params: user_params) - - expect(ActionMailer::Base.deliveries.last.to.first).to eq(user_params[:user][:email]) - expect(subject.current_user).to be_nil + expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions) + expect(controller.current_user).to be_nil end end @@ -125,9 +221,8 @@ RSpec.describe RegistrationsController do end it 'authenticates the user and sends a confirmation email' do - post(:create, params: user_params) - - expect(ActionMailer::Base.deliveries.last.to.first).to eq(user_params[:user][:email]) + expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions) + expect(controller.current_user).to be_present expect(response).to redirect_to(users_sign_up_welcome_path) end end @@ -137,7 +232,7 @@ RSpec.describe RegistrationsController do it 'redirects to sign_in' do stub_application_setting(signup_enabled: false) - expect { post(:create, params: user_params) }.not_to change(User, :count) + expect { subject }.not_to change(User, :count) expect(response).to redirect_to(new_user_session_path) end end @@ -158,14 +253,14 @@ RSpec.describe RegistrationsController do it 'displays an error when the reCAPTCHA is not solved' do allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false) - post(:create, params: user_params) + subject expect(response).to render_template(:new) expect(flash[:alert]).to eq(_('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')) end it 'redirects to the welcome page when the reCAPTCHA is solved' do - post(:create, params: user_params) + subject expect(response).to redirect_to(users_sign_up_welcome_path) end @@ -264,7 +359,7 @@ RSpec.describe RegistrationsController do end it 'redirects back with a notice when the checkbox was not checked' do - post :create, params: user_params + subject expect(flash[:alert]).to eq(_('You must accept our Terms of Service and privacy policy in order to register an account')) end @@ -272,8 +367,8 @@ RSpec.describe RegistrationsController do it 'creates the user with agreement when terms are accepted' do post :create, params: user_params.merge(terms_opt_in: '1') - expect(subject.current_user).to be_present - expect(subject.current_user.terms_accepted?).to be(true) + expect(controller.current_user).to be_present + expect(controller.current_user.terms_accepted?).to be(true) end context 'when experiment terms_opt_in is enabled' do @@ -287,10 +382,10 @@ RSpec.describe RegistrationsController do end it 'creates the user with accepted terms' do - post :create, params: user_params + subject - expect(subject.current_user).to be_present - expect(subject.current_user.terms_accepted?).to be(true) + expect(controller.current_user).to be_present + expect(controller.current_user.terms_accepted?).to be(true) end end @@ -300,7 +395,7 @@ RSpec.describe RegistrationsController do end it 'creates the user without accepted terms' do - post :create, params: user_params + subject expect(flash[:alert]).to eq(_('You must accept our Terms of Service and privacy policy in order to register an account')) end @@ -310,8 +405,6 @@ RSpec.describe RegistrationsController do describe 'tracking data' do context 'with sign up flow and terms_opt_in experiment being enabled' do - subject { post :create, params: user_params } - before do stub_experiment(signup_flow: true, terms_opt_in: true) end @@ -361,13 +454,13 @@ RSpec.describe RegistrationsController do it "logs a 'User Created' message" do expect(Gitlab::AppLogger).to receive(:info).with(/\AUser Created: username=new_username email=new@user.com.+\z/).and_call_original - post(:create, params: user_params) + subject end it 'handles when params are new_user' do post(:create, params: { new_user: base_user_params }) - expect(subject.current_user).not_to be_nil + expect(controller.current_user).not_to be_nil end it 'sets name from first and last name' do diff --git a/spec/factories/alert_management/alerts.rb b/spec/factories/alert_management/alerts.rb index d0546657ccf..e36e4c38013 100644 --- a/spec/factories/alert_management/alerts.rb +++ b/spec/factories/alert_management/alerts.rb @@ -56,22 +56,22 @@ FactoryBot.define do end trait :triggered do - status { AlertManagement::Alert::STATUSES[:triggered] } + status { AlertManagement::Alert.status_value(:triggered) } without_ended_at end trait :acknowledged do - status { AlertManagement::Alert::STATUSES[:acknowledged] } + status { AlertManagement::Alert.status_value(:acknowledged) } without_ended_at end trait :resolved do - status { AlertManagement::Alert::STATUSES[:resolved] } + status { AlertManagement::Alert.status_value(:resolved) } with_ended_at end trait :ignored do - status { AlertManagement::Alert::STATUSES[:ignored] } + status { AlertManagement::Alert.status_value(:ignored) } without_ended_at end diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb index eed9a6d1043..5d141580874 100644 --- a/spec/features/labels_hierarchy_spec.rb +++ b/spec/features/labels_hierarchy_spec.rb @@ -17,6 +17,7 @@ RSpec.describe 'Labels Hierarchy', :js do let!(:project_label_1) { create(:label, project: project_1, title: 'Label_4') } before do + stub_feature_flags(graphql_board_lists: false) grandparent.add_owner(user) sign_in(user) diff --git a/spec/features/projects/badges/list_spec.rb b/spec/features/projects/badges/list_spec.rb index 3382bdcd65f..d1e635f11c0 100644 --- a/spec/features/projects/badges/list_spec.rb +++ b/spec/features/projects/badges/list_spec.rb @@ -17,10 +17,10 @@ RSpec.describe 'list of badges' do expect(page).to have_content 'Markdown' expect(page).to have_content 'HTML' expect(page).to have_content 'AsciiDoc' - expect(page).to have_css('.highlight', count: 3) + expect(page).to have_css('.js-syntax-highlight', count: 3) expect(page).to have_xpath("//img[@alt='pipeline status']") - page.within('.highlight', match: :first) do + page.within('.js-syntax-highlight', match: :first) do expect(page).to have_content 'badges/master/pipeline.svg' end end @@ -32,10 +32,10 @@ RSpec.describe 'list of badges' do expect(page).to have_content 'Markdown' expect(page).to have_content 'HTML' expect(page).to have_content 'AsciiDoc' - expect(page).to have_css('.highlight', count: 3) + expect(page).to have_css('.js-syntax-highlight', count: 3) expect(page).to have_xpath("//img[@alt='coverage report']") - page.within('.highlight', match: :first) do + page.within('.js-syntax-highlight', match: :first) do expect(page).to have_content 'badges/master/coverage.svg' end end diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index 0dff4c28270..6e18de3be7b 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -173,9 +173,9 @@ RSpec.describe 'Runners' do it 'user enables shared runners' do visit project_runners_path(project) - click_on 'Enable shared Runners' + click_on 'Enable shared runners' - expect(page.find('.shared-runners-description')).to have_content('Disable shared Runners') + expect(page.find('.shared-runners-description')).to have_content('Disable shared runners') end end diff --git a/spec/finders/alert_management/alerts_finder_spec.rb b/spec/finders/alert_management/alerts_finder_spec.rb index 926446b31d5..e6a3a176e67 100644 --- a/spec/finders/alert_management/alerts_finder_spec.rb +++ b/spec/finders/alert_management/alerts_finder_spec.rb @@ -39,19 +39,19 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do end context 'status given' do - let(:params) { { status: AlertManagement::Alert::STATUSES[:resolved] } } + let(:params) { { status: :resolved } } it { is_expected.to match_array(resolved_alert) } context 'with an array of statuses' do let(:triggered_alert) { create(:alert_management_alert) } - let(:params) { { status: [AlertManagement::Alert::STATUSES[:resolved]] } } + let(:params) { { status: [:resolved] } } it { is_expected.to match_array(resolved_alert) } end context 'with no alerts of status' do - let(:params) { { status: AlertManagement::Alert::STATUSES[:acknowledged] } } + let(:params) { { status: :acknowledged } } it { is_expected.to be_empty } end @@ -169,12 +169,6 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do end context 'when sorting by status' do - let(:statuses) { AlertManagement::Alert::STATUSES } - let(:triggered) { statuses[:triggered] } - let(:acknowledged) { statuses[:acknowledged] } - let(:resolved) { statuses[:resolved] } - let(:ignored) { statuses[:ignored] } - let_it_be(:alert_triggered) { create(:alert_management_alert, project: project) } let_it_be(:alert_acknowledged) { create(:alert_management_alert, :acknowledged, project: project) } let_it_be(:alert_resolved) { create(:alert_management_alert, :resolved, project: project) } @@ -184,7 +178,7 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do let(:params) { { sort: 'status_asc' } } it 'sorts by status: Ignored > Resolved > Acknowledged > Triggered' do - expect(execute.map(&:status).uniq).to eq([ignored, resolved, acknowledged, triggered]) + expect(execute.map(&:status_name).uniq).to eq([:ignored, :resolved, :acknowledged, :triggered]) end end @@ -192,7 +186,7 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do let(:params) { { sort: 'status_desc' } } it 'sorts by status: Triggered > Acknowledged > Resolved > Ignored' do - expect(execute.map(&:status).uniq).to eq([triggered, acknowledged, resolved, ignored]) + expect(execute.map(&:status_name).uniq).to eq([:triggered, :acknowledged, :resolved, :ignored]) end end end @@ -261,12 +255,12 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do project.add_developer(current_user) end - it { is_expected.to match({ 2 => 1, 3 => 1 }) } # one resolved and one ignored + it { is_expected.to match(resolved: 1, ignored: 1) } context 'when filtering params are included' do - let(:params) { { status: AlertManagement::Alert::STATUSES[:resolved] } } + let(:params) { { status: :resolved } } - it { is_expected.to match({ 2 => 1 }) } # one resolved + it { is_expected.to match(resolved: 1) } end end end diff --git a/spec/frontend/boards/board_list_new_spec.js b/spec/frontend/boards/board_list_new_spec.js new file mode 100644 index 00000000000..163611c2197 --- /dev/null +++ b/spec/frontend/boards/board_list_new_spec.js @@ -0,0 +1,234 @@ +/* global List */ +/* global ListIssue */ + +import Vuex from 'vuex'; +import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; +import { createLocalVue, mount } from '@vue/test-utils'; +import eventHub from '~/boards/eventhub'; +import BoardList from '~/boards/components/board_list_new.vue'; +import BoardCard from '~/boards/components/board_card.vue'; +import '~/boards/models/issue'; +import '~/boards/models/list'; +import { listObj, mockIssuesByListId, issues } from './mock_data'; +import defaultState from '~/boards/stores/state'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const actions = { + fetchIssuesForList: jest.fn(), +}; + +const createStore = (state = defaultState) => { + return new Vuex.Store({ + state, + actions, + }); +}; + +const createComponent = ({ + listIssueProps = {}, + componentProps = {}, + listProps = {}, + state = {}, +} = {}) => { + const store = createStore({ + issuesByListId: mockIssuesByListId, + issues, + pageInfoByListId: { + 'gid://gitlab/List/1': { hasNextPage: true }, + 'gid://gitlab/List/2': {}, + }, + listsFlags: { + 'gid://gitlab/List/1': {}, + 'gid://gitlab/List/2': {}, + }, + ...state, + }); + + const list = new List({ + ...listObj, + id: 'gid://gitlab/List/1', + ...listProps, + doNotFetchIssues: true, + }); + const issue = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [], + assignees: [], + ...listIssueProps, + }); + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { + list.issuesSize = 1; + } + + const component = mount(BoardList, { + localVue, + propsData: { + disabled: false, + list, + issues: [issue], + ...componentProps, + }, + store, + provide: { + groupId: null, + rootPath: '/', + }, + }); + + return component; +}; + +describe('Board list component', () => { + let wrapper; + useFakeRequestAnimationFrame(); + + describe('When Expanded', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders component', () => { + expect(wrapper.find('.board-list-component').exists()).toBe(true); + }); + + it('renders loading icon', () => { + wrapper = createComponent({ + state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } }, + }); + + expect(wrapper.find('[data-testid="board_list_loading"').exists()).toBe(true); + }); + + it('renders issues', () => { + expect(wrapper.findAll(BoardCard).length).toBe(1); + }); + + it('sets data attribute with issue id', () => { + expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1'); + }); + + it('shows new issue form', async () => { + wrapper.vm.toggleForm(); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); + }); + + it('shows new issue form after eventhub event', async () => { + eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); + }); + + it('does not show new issue form for closed list', () => { + wrapper.setProps({ list: { type: 'closed' } }); + wrapper.vm.toggleForm(); + + expect(wrapper.find('.board-new-issue-form').exists()).toBe(false); + }); + + it('shows count list item', async () => { + wrapper.vm.showCount = true; + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').exists()).toBe(true); + + expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues'); + }); + + it('sets data attribute with invalid id', async () => { + wrapper.vm.showCount = true; + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1'); + }); + + it('shows how many more issues to load', async () => { + wrapper.vm.showCount = true; + wrapper.setProps({ list: { issuesSize: 20 } }); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues'); + }); + }); + + describe('load more issues', () => { + beforeEach(() => { + wrapper = createComponent({ + listProps: { issuesSize: 25 }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('loads more issues after scrolling', () => { + wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); + + expect(actions.fetchIssuesForList).toHaveBeenCalled(); + }); + + it('does not load issues if already loading', () => { + wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); + wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); + + expect(actions.fetchIssuesForList).toHaveBeenCalledTimes(1); + }); + + it('shows loading more spinner', async () => { + wrapper.vm.showCount = true; + wrapper.vm.list.loadingMore = true; + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true); + }); + }); + + describe('max issue count warning', () => { + beforeEach(() => { + wrapper = createComponent({ + listProps: { issuesSize: 50 }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when issue count exceeds max issue count', () => { + it('sets background to bg-danger-100', async () => { + wrapper.setProps({ list: { issuesSize: 4, maxIssueCount: 3 } }); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.bg-danger-100').exists()).toBe(true); + }); + }); + + describe('when list issue count does NOT exceed list max issue count', () => { + it('does not sets background to bg-danger-100', () => { + wrapper.setProps({ list: { issuesSize: 2, maxIssueCount: 3 } }); + + expect(wrapper.find('.bg-danger-100').exists()).toBe(false); + }); + }); + + describe('when list max issue count is 0', () => { + it('does not sets background to bg-danger-100', () => { + wrapper.setProps({ list: { maxIssueCount: 0 } }); + + expect(wrapper.find('.bg-danger-100').exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 88883ae61d4..0fe3c88f518 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -44,7 +44,6 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP disabled: false, list, issues: list.issues, - loading: false, ...componentProps, }, provide: { @@ -94,7 +93,7 @@ describe('Board list component', () => { }); it('renders loading icon', () => { - component.loading = true; + component.list.loading = true; return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 6415a5a5d3a..0e5fee9a563 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -250,6 +250,13 @@ describe('fetchIssuesForList', () => { boardType: 'group', }; + const mockIssuesNodes = mockIssues.map(issue => ({ node: issue })); + + const pageInfo = { + endCursor: '', + hasNextPage: false, + }; + const queryResponse = { data: { group: { @@ -259,7 +266,8 @@ describe('fetchIssuesForList', () => { { id: listId, issues: { - nodes: mockIssues, + edges: mockIssuesNodes, + pageInfo, }, }, ], @@ -271,17 +279,25 @@ describe('fetchIssuesForList', () => { const formattedIssues = formatListIssues(queryResponse.data.group.board.lists); - it('should commit mutation RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => { + const listPageInfo = { + [listId]: pageInfo, + }; + + it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( actions.fetchIssuesForList, - listId, + { listId }, state, [ { + type: types.REQUEST_ISSUES_FOR_LIST, + payload: { listId, fetchNext: false }, + }, + { type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, - payload: { listIssues: formattedIssues, listId }, + payload: { listIssues: formattedIssues, listPageInfo, listId }, }, ], [], @@ -289,14 +305,20 @@ describe('fetchIssuesForList', () => { ); }); - it('should commit mutation RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => { + it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); testAction( actions.fetchIssuesForList, - listId, + { listId }, state, - [{ type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId }], + [ + { + type: types.REQUEST_ISSUES_FOR_LIST, + payload: { listId, fetchNext: false }, + }, + { type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId }, + ], [], done, ); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 306f24e5240..4e60a78f443 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -173,13 +173,23 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: {}, + issuesByListId: { + 'gid://gitlab/List/1': [], + }, issues: {}, boardLists: mockListsWithModel, }; + const listPageInfo = { + 'gid://gitlab/List/1': { + endCursor: '', + hasNextPage: false, + }, + }; + mutations.RECEIVE_ISSUES_FOR_LIST_SUCCESS(state, { listIssues: { listData: listIssues, issues }, + listPageInfo, listId: 'gid://gitlab/List/1', }); diff --git a/spec/frontend/group_settings/components/shared_runners_form_spec.js b/spec/frontend/group_settings/components/shared_runners_form_spec.js new file mode 100644 index 00000000000..9e3ee8a2cb1 --- /dev/null +++ b/spec/frontend/group_settings/components/shared_runners_form_spec.js @@ -0,0 +1,169 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import MockAxiosAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import SharedRunnersForm from '~/group_settings/components/shared_runners_form.vue'; +import { ENABLED, DISABLED, ALLOW_OVERRIDE } from '~/group_settings/constants'; +import axios from '~/lib/utils/axios_utils'; + +const TEST_UPDATE_PATH = '/test/update'; +const DISABLED_PAYLOAD = { shared_runners_setting: DISABLED }; +const ENABLED_PAYLOAD = { shared_runners_setting: ENABLED }; +const OVERRIDE_PAYLOAD = { shared_runners_setting: ALLOW_OVERRIDE }; + +jest.mock('~/flash'); + +describe('group_settings/components/shared_runners_form', () => { + let wrapper; + let mock; + + const createComponent = (props = {}) => { + wrapper = shallowMount(SharedRunnersForm, { + propsData: { + updatePath: TEST_UPDATE_PATH, + sharedRunnersAvailability: ENABLED, + parentSharedRunnersAvailability: null, + ...props, + }, + }); + }; + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findErrorAlert = () => wrapper.find(GlAlert); + const findEnabledToggle = () => wrapper.find('[data-testid="enable-runners-toggle"]'); + const findOverrideToggle = () => wrapper.find('[data-testid="override-runners-toggle"]'); + const changeToggle = toggle => toggle.vm.$emit('change', !toggle.props('value')); + const getRequestPayload = () => JSON.parse(mock.history.put[0].data); + const isLoadingIconVisible = () => findLoadingIcon().exists(); + + beforeEach(() => { + mock = new MockAxiosAdapter(axios); + + mock.onPut(TEST_UPDATE_PATH).reply(200); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + mock.restore(); + }); + + describe('with default', () => { + beforeEach(() => { + createComponent(); + }); + + it('loading icon does not exist', () => { + expect(isLoadingIconVisible()).toBe(false); + }); + + it('enabled toggle exists', () => { + expect(findEnabledToggle().exists()).toBe(true); + }); + + it('override toggle does not exist', () => { + expect(findOverrideToggle().exists()).toBe(false); + }); + }); + + describe('loading icon', () => { + it('shows and hides the loading icon on request', async () => { + createComponent(); + + expect(isLoadingIconVisible()).toBe(false); + + findEnabledToggle().vm.$emit('change', true); + + await wrapper.vm.$nextTick(); + + expect(isLoadingIconVisible()).toBe(true); + + await waitForPromises(); + + expect(isLoadingIconVisible()).toBe(false); + }); + }); + + describe('enable toggle', () => { + beforeEach(() => { + createComponent(); + }); + + it('enabling the toggle sends correct payload', async () => { + findEnabledToggle().vm.$emit('change', true); + + await waitForPromises(); + + expect(getRequestPayload()).toEqual(ENABLED_PAYLOAD); + expect(findOverrideToggle().exists()).toBe(false); + }); + + it('disabling the toggle sends correct payload', async () => { + findEnabledToggle().vm.$emit('change', false); + + await waitForPromises(); + + expect(getRequestPayload()).toEqual(DISABLED_PAYLOAD); + expect(findOverrideToggle().exists()).toBe(true); + }); + }); + + describe('override toggle', () => { + beforeEach(() => { + createComponent({ sharedRunnersAvailability: ALLOW_OVERRIDE }); + }); + + it('enabling the override toggle sends correct payload', async () => { + findOverrideToggle().vm.$emit('change', true); + + await waitForPromises(); + + expect(getRequestPayload()).toEqual(OVERRIDE_PAYLOAD); + }); + + it('disabling the override toggle sends correct payload', async () => { + findOverrideToggle().vm.$emit('change', false); + + await waitForPromises(); + + expect(getRequestPayload()).toEqual(DISABLED_PAYLOAD); + }); + }); + + describe('toggle disabled state', () => { + it(`toggles are not disabled with setting ${DISABLED}`, () => { + createComponent({ sharedRunnersAvailability: DISABLED }); + expect(findEnabledToggle().props('disabled')).toBe(false); + expect(findOverrideToggle().props('disabled')).toBe(false); + }); + + it('toggles are disabled', () => { + createComponent({ + sharedRunnersAvailability: DISABLED, + parentSharedRunnersAvailability: DISABLED, + }); + expect(findEnabledToggle().props('disabled')).toBe(true); + expect(findOverrideToggle().props('disabled')).toBe(true); + }); + }); + + describe.each` + errorObj | message + ${{}} | ${'An error occurred while updating configuration. Refresh the page and try again.'} + ${{ error: 'Undefined error' }} | ${'Undefined error Refresh the page and try again.'} + `(`with error $errorObj`, ({ errorObj, message }) => { + beforeEach(async () => { + mock.onPut(TEST_UPDATE_PATH).reply(500, errorObj); + + createComponent(); + changeToggle(findEnabledToggle()); + + await waitForPromises(); + }); + + it('error should be shown', () => { + expect(findErrorAlert().text()).toBe(message); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js new file mode 100644 index 00000000000..0e157f8efdf --- /dev/null +++ b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js @@ -0,0 +1,72 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlFormInput, GlFormTextarea } from '@gitlab/ui'; + +import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue'; + +import { mergeRequestMeta } from '../mock_data'; + +describe('~/static_site_editor/components/edit_meta_modal.vue', () => { + let wrapper; + const { title, description } = mergeRequestMeta; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(EditMetaControls, { + propsData: { + title, + description, + ...propsData, + }, + }); + }; + + const findGlFormInputTitle = () => wrapper.find(GlFormInput); + const findGlFormTextAreaDescription = () => wrapper.find(GlFormTextarea); + + beforeEach(() => { + buildWrapper(); + + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the title input', () => { + expect(findGlFormInputTitle().exists()).toBe(true); + }); + + it('renders the description input', () => { + expect(findGlFormTextAreaDescription().exists()).toBe(true); + }); + + it('forwards the title prop to the title input', () => { + expect(findGlFormInputTitle().attributes().value).toBe(title); + }); + + it('forwards the description prop to the description input', () => { + expect(findGlFormTextAreaDescription().attributes().value).toBe(description); + }); + + it('emits updated settings when title input updates', () => { + const newTitle = 'New title'; + + findGlFormInputTitle().vm.$emit('input', newTitle); + + const newSettings = { description, title: newTitle }; + + expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings); + }); + + it('emits updated settings when description textarea updates', () => { + const newDescription = 'New description'; + + findGlFormTextAreaDescription().vm.$emit('input', newDescription); + + const newSettings = { description: newDescription, title }; + + expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings); + }); +}); diff --git a/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js new file mode 100644 index 00000000000..3fbd3542ed3 --- /dev/null +++ b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js @@ -0,0 +1,66 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlModal } from '@gitlab/ui'; + +import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.vue'; +import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue'; + +import { sourcePath, mergeRequestMeta } from '../mock_data'; + +describe('~/static_site_editor/components/edit_meta_modal.vue', () => { + let wrapper; + const { title, description } = mergeRequestMeta; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(EditMetaModal, { + propsData: { + sourcePath, + ...propsData, + }, + }); + }; + + const findGlModal = () => wrapper.find(GlModal); + const findEditMetaControls = () => wrapper.find(EditMetaControls); + + beforeEach(() => { + buildWrapper(); + + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the modal', () => { + expect(findGlModal().exists()).toBe(true); + }); + + it('renders the edit meta controls', () => { + expect(findEditMetaControls().exists()).toBe(true); + }); + + it('contains the sourcePath in the title', () => { + expect(findEditMetaControls().props('title')).toContain(sourcePath); + }); + + it('forwards the title prop', () => { + expect(findEditMetaControls().props('title')).toBe(title); + }); + + it('forwards the description prop', () => { + expect(findEditMetaControls().props('description')).toBe(description); + }); + + it('emits the primary event with mergeRequestMeta', () => { + findGlModal().vm.$emit('primary', mergeRequestMeta); + expect(wrapper.emitted('primary')).toEqual([[mergeRequestMeta]]); + }); + + it('emits the hide event', () => { + findGlModal().vm.$emit('hide'); + expect(wrapper.emitted('hide')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index 10d34d9651c..2c69e884005 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -3,6 +3,7 @@ import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import Home from '~/static_site_editor/pages/home.vue'; import SkeletonLoader from '~/static_site_editor/components/skeleton_loader.vue'; import EditArea from '~/static_site_editor/components/edit_area.vue'; +import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.vue'; import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue'; import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue'; import submitContentChangesMutation from '~/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql'; @@ -21,6 +22,7 @@ import { savedContentMeta, submitChangesError, trackingCategory, + images, } from '../mock_data'; const localVue = createLocalVue(); @@ -85,6 +87,7 @@ describe('static_site_editor/pages/home', () => { }; const findEditArea = () => wrapper.find(EditArea); + const findEditMetaModal = () => wrapper.find(EditMetaModal); const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage); const findSkeletonLoader = () => wrapper.find(SkeletonLoader); const findSubmitChangesError = () => wrapper.find(SubmitChangesError); @@ -152,17 +155,38 @@ describe('static_site_editor/pages/home', () => { }); it('displays invalid content message when content is not supported', () => { - buildWrapper({ appData: { isSupportedContent: false } }); + buildWrapper({ appData: { ...defaultAppData, isSupportedContent: false } }); expect(findInvalidContentMessage().exists()).toBe(true); }); it('does not display invalid content message when content is supported', () => { - buildWrapper({ appData: { isSupportedContent: true } }); + buildWrapper(); expect(findInvalidContentMessage().exists()).toBe(false); }); + it('renders an EditMetaModal component', () => { + buildWrapper(); + + expect(findEditMetaModal().exists()).toBe(true); + }); + + describe('when preparing submission', () => { + it('calls the show method when the edit-area submit event is emitted', () => { + buildWrapper(); + + const mockInstance = { show: jest.fn() }; + wrapper.vm.$refs.editMetaModal = mockInstance; + + findEditArea().vm.$emit('submit', { content }); + + return wrapper.vm.$nextTick().then(() => { + expect(mockInstance.show).toHaveBeenCalled(); + }); + }); + }); + describe('when submitting changes fails', () => { const setupMutateMock = () => { mutateMock @@ -173,8 +197,8 @@ describe('static_site_editor/pages/home', () => { beforeEach(() => { setupMutateMock(); - buildWrapper(); - findEditArea().vm.$emit('submit', { content }); + buildWrapper({ content }); + findEditMetaModal().vm.$emit('primary', mergeRequestMeta); return wrapper.vm.$nextTick(); }); @@ -200,12 +224,6 @@ describe('static_site_editor/pages/home', () => { }); }); - it('does not display submit changes error when an error does not exist', () => { - buildWrapper(); - - expect(findSubmitChangesError().exists()).toBe(false); - }); - describe('when submitting changes succeeds', () => { const newContent = `new ${content}`; @@ -216,8 +234,8 @@ describe('static_site_editor/pages/home', () => { }, }); - buildWrapper(); - findEditArea().vm.$emit('submit', { content: newContent }); + buildWrapper({ content: newContent, images }); + findEditMetaModal().vm.$emit('primary', mergeRequestMeta); return wrapper.vm.$nextTick(); }); @@ -242,7 +260,7 @@ describe('static_site_editor/pages/home', () => { project, sourcePath, username, - images: undefined, + images, mergeRequestMeta, }, }, @@ -254,6 +272,12 @@ describe('static_site_editor/pages/home', () => { }); }); + it('does not display submit changes error when an error does not exist', () => { + buildWrapper(); + + expect(findSubmitChangesError().exists()).toBe(false); + }); + it('tracks when editor is initialized on the mounted lifecycle hook', () => { buildWrapper(); expect(trackingSpy).toHaveBeenCalledWith( diff --git a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js index 98e04c6babd..1374cdc6aef 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js +++ b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; +import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue'; import { invite as member } from '../mock_data'; describe('InviteActionButtons', () => { @@ -16,6 +17,7 @@ describe('InviteActionButtons', () => { }; const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); + const findResendInviteButton = () => wrapper.find(ResendInviteButton); afterEach(() => { wrapper.destroy(); @@ -56,4 +58,28 @@ describe('InviteActionButtons', () => { expect(findRemoveMemberButton().exists()).toBe(false); }); }); + + describe('when user has `canResend` permissions', () => { + it('renders resend invite button', () => { + createComponent({ + permissions: { + canResend: true, + }, + }); + + expect(findResendInviteButton().exists()).toBe(true); + }); + }); + + describe('when user does not have `canResend` permissions', () => { + it('does not render resend invite button', () => { + createComponent({ + permissions: { + canResend: false, + }, + }); + + expect(findResendInviteButton().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js new file mode 100644 index 00000000000..859fdd01043 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js @@ -0,0 +1,66 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ResendInviteButton', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(ResendInviteButton, { + localVue, + store: createStore(state), + propsData: { + memberId: 1, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findForm = () => wrapper.find('form'); + const findButton = () => findForm().find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a tooltip', () => { + expect(getBinding(findButton().element, 'gl-tooltip')).not.toBeUndefined(); + expect(findButton().attributes('title')).toBe('Resend invite'); + }); + + it('submits the form when button is clicked', () => { + expect(findButton().attributes('type')).toBe('submit'); + }); + + it('displays form with correct action and inputs', () => { + expect(findForm().attributes('action')).toBe('/groups/foo-bar/-/group_members/1/resend_invite'); + expect( + findForm() + .find('input[name="authenticity_token"]') + .attributes('value'), + ).toBe('mock-csrf-token'); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js index e9b5223df73..139093d5a9c 100644 --- a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js +++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js @@ -169,5 +169,39 @@ describe('MemberList', () => { }); }); }); + + describe('canResend', () => { + describe('when member type is `invite`', () => { + it('returns `true` when `canResend` is `true`', () => { + createComponent({ + member: invite, + }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(true); + }); + + it('returns `false` when `canResend` is `false`', () => { + createComponent({ + member: { + ...invite, + invite: { + ...invite, + canResend: false, + }, + }, + }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(false); + }); + }); + + describe('when member type is not `invite`', () => { + it('returns `false`', () => { + createComponent({ member: memberMock }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(false); + }); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js index e2cfdedb4bf..2a48bf4f2d6 100644 --- a/spec/frontend/vue_shared/components/registry/list_item_spec.js +++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js @@ -58,9 +58,9 @@ describe('list item', () => { describe.each` slotNames - ${['details_foo']} - ${['details_foo', 'details_bar']} - ${['details_foo', 'details_bar', 'details_baz']} + ${['details-foo']} + ${['details-foo', 'details-bar']} + ${['details-foo', 'details-bar', 'details-baz']} `('$slotNames details slots', ({ slotNames }) => { const slotMocks = slotNames.reduce((acc, current) => { acc[current] = `<div data-testid="${current}" />`; @@ -89,7 +89,7 @@ describe('list item', () => { describe('details toggle button', () => { it('is visible when at least one details slot exists', async () => { - mountComponent({}, { details_foo: '<span></span>' }); + mountComponent({}, { 'details-foo': '<span></span>' }); await wrapper.vm.$nextTick(); expect(findToggleDetailsButton().exists()).toBe(true); }); diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index a513f178f45..5cb606b58d9 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -79,9 +79,9 @@ describe('title area', () => { describe.each` slotNames - ${['metadata_foo']} - ${['metadata_foo', 'metadata_bar']} - ${['metadata_foo', 'metadata_bar', 'metadata_baz']} + ${['metadata-foo']} + ${['metadata-foo', 'metadata-bar']} + ${['metadata-foo', 'metadata-bar', 'metadata-baz']} `('$slotNames metadata slots', ({ slotNames }) => { const slotMocks = slotNames.reduce((acc, current) => { acc[current] = `<div data-testid="${current}" />`; diff --git a/spec/graphql/types/alert_management/status_enum_spec.rb b/spec/graphql/types/alert_management/status_enum_spec.rb index ac7a8eb53f6..1252efabe4c 100644 --- a/spec/graphql/types/alert_management/status_enum_spec.rb +++ b/spec/graphql/types/alert_management/status_enum_spec.rb @@ -9,10 +9,10 @@ RSpec.describe GitlabSchema.types['AlertManagementStatus'] do using RSpec::Parameterized::TableSyntax where(:status_name, :status_value) do - 'TRIGGERED' | 0 - 'ACKNOWLEDGED' | 1 - 'RESOLVED' | 2 - 'IGNORED' | 3 + 'TRIGGERED' | :triggered + 'ACKNOWLEDGED' | :acknowledged + 'RESOLVED' | :resolved + 'IGNORED' | :ignored end with_them do diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 06f86e7716a..98ee7c7b97c 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -5,16 +5,6 @@ require 'spec_helper' RSpec.describe BlobHelper do include TreeHelper - describe '#highlight' do - it 'wraps highlighted content' do - expect(helper.highlight('test.rb', '52')).to eq(%q[<pre class="code highlight"><code><span id="LC1" class="line" lang="ruby"><span class="mi">52</span></span></code></pre>]) - end - - it 'handles plain version' do - expect(helper.highlight('test.rb', '52', plain: true)).to eq(%q[<pre class="code highlight"><code><span id="LC1" class="line" lang="">52</span></code></pre>]) - end - end - describe "#sanitize_svg_data" do let(:input_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'unsanitized.svg') } let(:data) { File.read(input_svg_path) } diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index a006933a2a5..38caae91ef2 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -53,4 +53,25 @@ RSpec.describe Ci::RunnersHelper do end end end + + describe '#group_shared_runners_settings_data' do + let(:group) { create(:group, parent: parent, shared_runners_enabled: false) } + let(:parent) { create(:group) } + + it 'returns group data for top level group' do + data = group_shared_runners_settings_data(parent) + + expect(data[:update_path]).to eq("/api/v4/groups/#{parent.id}") + expect(data[:shared_runners_availability]).to eq('enabled') + expect(data[:parent_shared_runners_availability]).to eq(nil) + end + + it 'returns group data for child group' do + data = group_shared_runners_settings_data(group) + + expect(data[:update_path]).to eq("/api/v4/groups/#{group.id}") + expect(data[:shared_runners_availability]).to eq('disabled_and_unoverridable') + expect(data[:parent_shared_runners_availability]).to eq('enabled') + end + end end diff --git a/spec/helpers/icons_helper_spec.rb b/spec/helpers/icons_helper_spec.rb index 872aa821560..94012de3877 100644 --- a/spec/helpers/icons_helper_spec.rb +++ b/spec/helpers/icons_helper_spec.rb @@ -114,128 +114,128 @@ RSpec.describe IconsHelper do end describe 'file_type_icon_class' do - it 'returns folder class' do - expect(file_type_icon_class('folder', 0, 'folder_name')).to eq 'folder' + it 'returns folder-o class' do + expect(file_type_icon_class('folder', 0, 'folder_name')).to eq 'folder-o' end it 'returns share class' do expect(file_type_icon_class('file', '120000', 'link')).to eq 'share' end - it 'returns file-pdf-o class with .pdf' do - expect(file_type_icon_class('file', 0, 'filename.pdf')).to eq 'file-pdf-o' + it 'returns document class with .pdf' do + expect(file_type_icon_class('file', 0, 'filename.pdf')).to eq 'document' end - it 'returns file-image-o class with .jpg' do - expect(file_type_icon_class('file', 0, 'filename.jpg')).to eq 'file-image-o' + it 'returns doc-image class with .jpg' do + expect(file_type_icon_class('file', 0, 'filename.jpg')).to eq 'doc-image' end - it 'returns file-image-o class with .JPG' do - expect(file_type_icon_class('file', 0, 'filename.JPG')).to eq 'file-image-o' + it 'returns doc-image class with .JPG' do + expect(file_type_icon_class('file', 0, 'filename.JPG')).to eq 'doc-image' end - it 'returns file-image-o class with .png' do - expect(file_type_icon_class('file', 0, 'filename.png')).to eq 'file-image-o' + it 'returns doc-image class with .png' do + expect(file_type_icon_class('file', 0, 'filename.png')).to eq 'doc-image' end - it 'returns file-image-o class with .apng' do - expect(file_type_icon_class('file', 0, 'filename.apng')).to eq 'file-image-o' + it 'returns doc-image class with .apng' do + expect(file_type_icon_class('file', 0, 'filename.apng')).to eq 'doc-image' end - it 'returns file-image-o class with .webp' do - expect(file_type_icon_class('file', 0, 'filename.webp')).to eq 'file-image-o' + it 'returns doc-image class with .webp' do + expect(file_type_icon_class('file', 0, 'filename.webp')).to eq 'doc-image' end - it 'returns file-archive-o class with .tar' do - expect(file_type_icon_class('file', 0, 'filename.tar')).to eq 'file-archive-o' + it 'returns doc-compressed class with .tar' do + expect(file_type_icon_class('file', 0, 'filename.tar')).to eq 'doc-compressed' end - it 'returns file-archive-o class with .TAR' do - expect(file_type_icon_class('file', 0, 'filename.TAR')).to eq 'file-archive-o' + it 'returns doc-compressed class with .TAR' do + expect(file_type_icon_class('file', 0, 'filename.TAR')).to eq 'doc-compressed' end - it 'returns file-archive-o class with .tar.gz' do - expect(file_type_icon_class('file', 0, 'filename.tar.gz')).to eq 'file-archive-o' + it 'returns doc-compressed class with .tar.gz' do + expect(file_type_icon_class('file', 0, 'filename.tar.gz')).to eq 'doc-compressed' end - it 'returns file-audio-o class with .mp3' do - expect(file_type_icon_class('file', 0, 'filename.mp3')).to eq 'file-audio-o' + it 'returns volume-up class with .mp3' do + expect(file_type_icon_class('file', 0, 'filename.mp3')).to eq 'volume-up' end - it 'returns file-audio-o class with .MP3' do - expect(file_type_icon_class('file', 0, 'filename.MP3')).to eq 'file-audio-o' + it 'returns volume-up class with .MP3' do + expect(file_type_icon_class('file', 0, 'filename.MP3')).to eq 'volume-up' end - it 'returns file-audio-o class with .m4a' do - expect(file_type_icon_class('file', 0, 'filename.m4a')).to eq 'file-audio-o' + it 'returns volume-up class with .m4a' do + expect(file_type_icon_class('file', 0, 'filename.m4a')).to eq 'volume-up' end - it 'returns file-audio-o class with .wav' do - expect(file_type_icon_class('file', 0, 'filename.wav')).to eq 'file-audio-o' + it 'returns volume-up class with .wav' do + expect(file_type_icon_class('file', 0, 'filename.wav')).to eq 'volume-up' end - it 'returns file-video-o class with .avi' do - expect(file_type_icon_class('file', 0, 'filename.avi')).to eq 'file-video-o' + it 'returns live-preview class with .avi' do + expect(file_type_icon_class('file', 0, 'filename.avi')).to eq 'live-preview' end - it 'returns file-video-o class with .AVI' do - expect(file_type_icon_class('file', 0, 'filename.AVI')).to eq 'file-video-o' + it 'returns live-preview class with .AVI' do + expect(file_type_icon_class('file', 0, 'filename.AVI')).to eq 'live-preview' end - it 'returns file-video-o class with .mp4' do - expect(file_type_icon_class('file', 0, 'filename.mp4')).to eq 'file-video-o' + it 'returns live-preview class with .mp4' do + expect(file_type_icon_class('file', 0, 'filename.mp4')).to eq 'live-preview' end - it 'returns file-word-o class with .odt' do - expect(file_type_icon_class('file', 0, 'filename.odt')).to eq 'file-word-o' + it 'returns doc-text class with .odt' do + expect(file_type_icon_class('file', 0, 'filename.odt')).to eq 'doc-text' end - it 'returns file-word-o class with .doc' do - expect(file_type_icon_class('file', 0, 'filename.doc')).to eq 'file-word-o' + it 'returns doc-text class with .doc' do + expect(file_type_icon_class('file', 0, 'filename.doc')).to eq 'doc-text' end - it 'returns file-word-o class with .DOC' do - expect(file_type_icon_class('file', 0, 'filename.DOC')).to eq 'file-word-o' + it 'returns doc-text class with .DOC' do + expect(file_type_icon_class('file', 0, 'filename.DOC')).to eq 'doc-text' end - it 'returns file-word-o class with .docx' do - expect(file_type_icon_class('file', 0, 'filename.docx')).to eq 'file-word-o' + it 'returns doc-text class with .docx' do + expect(file_type_icon_class('file', 0, 'filename.docx')).to eq 'doc-text' end - it 'returns file-excel-o class with .xls' do - expect(file_type_icon_class('file', 0, 'filename.xls')).to eq 'file-excel-o' + it 'returns document class with .xls' do + expect(file_type_icon_class('file', 0, 'filename.xls')).to eq 'document' end - it 'returns file-excel-o class with .XLS' do - expect(file_type_icon_class('file', 0, 'filename.XLS')).to eq 'file-excel-o' + it 'returns document class with .XLS' do + expect(file_type_icon_class('file', 0, 'filename.XLS')).to eq 'document' end - it 'returns file-excel-o class with .xlsx' do - expect(file_type_icon_class('file', 0, 'filename.xlsx')).to eq 'file-excel-o' + it 'returns document class with .xlsx' do + expect(file_type_icon_class('file', 0, 'filename.xlsx')).to eq 'document' end - it 'returns file-excel-o class with .odp' do - expect(file_type_icon_class('file', 0, 'filename.odp')).to eq 'file-powerpoint-o' + it 'returns doc-chart class with .odp' do + expect(file_type_icon_class('file', 0, 'filename.odp')).to eq 'doc-chart' end - it 'returns file-excel-o class with .ppt' do - expect(file_type_icon_class('file', 0, 'filename.ppt')).to eq 'file-powerpoint-o' + it 'returns doc-chart class with .ppt' do + expect(file_type_icon_class('file', 0, 'filename.ppt')).to eq 'doc-chart' end - it 'returns file-excel-o class with .PPT' do - expect(file_type_icon_class('file', 0, 'filename.PPT')).to eq 'file-powerpoint-o' + it 'returns doc-chart class with .PPT' do + expect(file_type_icon_class('file', 0, 'filename.PPT')).to eq 'doc-chart' end - it 'returns file-excel-o class with .pptx' do - expect(file_type_icon_class('file', 0, 'filename.pptx')).to eq 'file-powerpoint-o' + it 'returns doc-chart class with .pptx' do + expect(file_type_icon_class('file', 0, 'filename.pptx')).to eq 'doc-chart' end - it 'returns file-text-o class with .unknow' do - expect(file_type_icon_class('file', 0, 'filename.unknow')).to eq 'file-text-o' + it 'returns doc-text class with .unknow' do + expect(file_type_icon_class('file', 0, 'filename.unknow')).to eq 'doc-text' end - it 'returns file-text-o class with no extension' do - expect(file_type_icon_class('file', 0, 'CHANGELOG')).to eq 'file-text-o' + it 'returns doc-text class with no extension' do + expect(file_type_icon_class('file', 0, 'CHANGELOG')).to eq 'doc-text' end end diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 0c982116103..b0ed5634680 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -408,4 +408,44 @@ RSpec.describe SearchHelper do it { is_expected.to eq('111111') } end end + + describe '#highlight_and_truncate_issue' do + let(:description) { 'hello world' } + let(:issue) { create(:issue, description: description) } + let(:user) { create(:user) } + + before do + allow(self).to receive(:current_user).and_return(user) + end + + subject { highlight_and_truncate_issue(issue, 'test', {}) } + + context 'when description is not present' do + let(:description) { nil } + + it 'does nothing' do + expect(self).not_to receive(:simple_search_highlight_and_truncate) + + subject + end + end + + context 'when description present' do + using RSpec::Parameterized::TableSyntax + + where(:description, :expected) do + 'test' | '<span class="gl-text-black-normal gl-font-weight-bold">test</span>' + '<span style="color: blue;">this test should not be blue</span>' | '<span>this <span class="gl-text-black-normal gl-font-weight-bold">test</span> should not be blue</span>' + '<a href="#" onclick="alert(\'XSS\')">Click Me test</a>' | '<a href="#">Click Me <span class="gl-text-black-normal gl-font-weight-bold">test</span></a>' + '<script type="text/javascript">alert(\'Another XSS\');</script> test' | ' <span class="gl-text-black-normal gl-font-weight-bold">test</span>' + 'Lorem test ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec.' | 'Lorem <span class="gl-text-black-normal gl-font-weight-bold">test</span> ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Don...' + end + + with_them do + it 'sanitizes, truncates, and highlights the search term' do + expect(subject).to eq(expected) + end + end + end + end end diff --git a/spec/lib/gitlab/alert_management/alert_status_counts_spec.rb b/spec/lib/gitlab/alert_management/alert_status_counts_spec.rb index a2b8f0aa8d4..fceda763717 100644 --- a/spec/lib/gitlab/alert_management/alert_status_counts_spec.rb +++ b/spec/lib/gitlab/alert_management/alert_status_counts_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Gitlab::AlertManagement::AlertStatusCounts do expect(counts.open).to eq(0) expect(counts.all).to eq(0) - AlertManagement::Alert::STATUSES.each_key do |status| + ::AlertManagement::Alert.status_names.each do |status| expect(counts.send(status)).to eq(0) end end @@ -39,7 +39,7 @@ RSpec.describe Gitlab::AlertManagement::AlertStatusCounts do end context 'when filtering params are included' do - let(:params) { { status: AlertManagement::Alert::STATUSES[:resolved] } } + let(:params) { { status: :resolved } } it 'returns the correct counts for each status' do expect(counts.open).to eq(0) diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb index b54fe40bb5f..80bd517ec92 100644 --- a/spec/lib/gitlab/conflict/file_spec.rb +++ b/spec/lib/gitlab/conflict/file_spec.rb @@ -262,7 +262,7 @@ RSpec.describe Gitlab::Conflict::File do end it 'includes the blob icon for the file' do - expect(conflict_file.as_json[:blob_icon]).to eq('file-text-o') + expect(conflict_file.as_json[:blob_icon]).to eq('doc-text') end context 'with the full_content option passed' do diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index cdb626778a8..57be9e93af2 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -60,6 +60,25 @@ RSpec.describe Gitlab::SearchResults do end end + describe '#highlight_map' do + using RSpec::Parameterized::TableSyntax + + where(:scope, :expected) do + 'projects' | {} + 'issues' | {} + 'merge_requests' | {} + 'milestones' | {} + 'users' | {} + 'unknown' | {} + end + + with_them do + it 'returns the expected highlight_map' do + expect(results.highlight_map(scope)).to eq(expected) + end + end + end + describe '#formatted_limited_count' do using RSpec::Parameterized::TableSyntax diff --git a/spec/lib/gitlab/snippet_search_results_spec.rb b/spec/lib/gitlab/snippet_search_results_spec.rb index e1ae26a4d9e..2177b2be6d6 100644 --- a/spec/lib/gitlab/snippet_search_results_spec.rb +++ b/spec/lib/gitlab/snippet_search_results_spec.rb @@ -21,6 +21,12 @@ RSpec.describe Gitlab::SnippetSearchResults do end end + describe '#highlight_map' do + it 'returns the expected highlight map' do + expect(results.highlight_map('snippet_titles')).to eq({}) + end + end + describe '#objects' do it 'uses page and per_page to paginate results' do snippet2 = create(:snippet, :public, content: 'foo', file_name: 'foo') diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb index ca5fb6f9d46..6b936e56ee9 100644 --- a/spec/models/alert_management/alert_spec.rb +++ b/spec/models/alert_management/alert_spec.rb @@ -189,14 +189,14 @@ RSpec.describe AlertManagement::Alert do end describe '.for_status' do - let(:status) { AlertManagement::Alert::STATUSES[:resolved] } + let(:status) { :resolved } subject { AlertManagement::Alert.for_status(status) } it { is_expected.to match_array(resolved_alert) } context 'with multiple statuses' do - let(:status) { AlertManagement::Alert::STATUSES.values_at(:resolved, :ignored) } + let(:status) { [:resolved, :ignored] } it { is_expected.to match_array([resolved_alert, ignored_alert]) } end @@ -241,19 +241,6 @@ RSpec.describe AlertManagement::Alert do it { is_expected.to eq([triggered_critical_alert, triggered_high_alert]) } end - describe '.counts_by_status' do - subject { described_class.counts_by_status } - - it do - is_expected.to eq( - triggered_alert.status => 1, - acknowledged_alert.status => 1, - resolved_alert.status => 1, - ignored_alert.status => 1 - ) - end - end - describe '.counts_by_project_id' do subject { described_class.counts_by_project_id } @@ -278,6 +265,55 @@ RSpec.describe AlertManagement::Alert do end end + describe '.status_value' do + using RSpec::Parameterized::TableSyntax + + where(:status, :status_value) do + :triggered | 0 + :acknowledged | 1 + :resolved | 2 + :ignored | 3 + :unknown | nil + end + + with_them do + it 'returns status value by its name' do + expect(described_class.status_value(status)).to eq(status_value) + end + end + end + + describe '.status_name' do + using RSpec::Parameterized::TableSyntax + + where(:raw_status, :status) do + 0 | :triggered + 1 | :acknowledged + 2 | :resolved + 3 | :ignored + -1 | nil + end + + with_them do + it 'returns status name by its values' do + expect(described_class.status_name(raw_status)).to eq(status) + end + end + end + + describe '.counts_by_status' do + subject { described_class.counts_by_status } + + it do + is_expected.to eq( + triggered: 1, + acknowledged: 1, + resolved: 1, + ignored: 1 + ) + end + end + describe '.last_prometheus_alert_by_project_id' do subject { described_class.last_prometheus_alert_by_project_id } diff --git a/spec/rubocop/cop/rspec/timecop_travel_spec.rb b/spec/rubocop/cop/rspec/timecop_travel_spec.rb new file mode 100644 index 00000000000..25a8127d40e --- /dev/null +++ b/spec/rubocop/cop/rspec/timecop_travel_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../../rubocop/cop/rspec/timecop_travel' + +RSpec.describe RuboCop::Cop::RSpec::TimecopTravel, type: :rubocop do + include CopHelper + + subject(:cop) { described_class.new } + + context 'when calling Timecop.travel' do + let(:source) do + <<~SRC + Timecop.travel(1.day.ago) { create(:issue) } + SRC + end + + let(:corrected_source) do + <<~SRC + travel_to(1.day.ago) { create(:issue) } + SRC + end + + it 'registers an offence' do + inspect_source(source) + + expect(cop.offenses.size).to eq(1) + end + + it 'can autocorrect the source' do + expect(autocorrect_source(source)).to eq(corrected_source) + end + end + + context 'when calling a different method on Timecop' do + let(:source) do + <<~SRC + Timecop.freeze { create(:issue) } + SRC + end + + it 'does not register an offence' do + inspect_source(source) + + expect(cop.offenses).to be_empty + end + end +end diff --git a/spec/serializers/blob_entity_spec.rb b/spec/serializers/blob_entity_spec.rb index b8c8c4c17de..27c62967755 100644 --- a/spec/serializers/blob_entity_spec.rb +++ b/spec/serializers/blob_entity_spec.rb @@ -22,7 +22,7 @@ RSpec.describe BlobEntity do name: blob.name, mode: "100644", readable_text: true, - icon: "file-text-o", + icon: "doc-text", url: "/#{project.full_path}/-/blob/master/bar/branch-test.txt" }) end diff --git a/spec/services/alert_management/alerts/update_service_spec.rb b/spec/services/alert_management/alerts/update_service_spec.rb index ee04fc55984..4b47efca9ed 100644 --- a/spec/services/alert_management/alerts/update_service_spec.rb +++ b/spec/services/alert_management/alerts/update_service_spec.rb @@ -160,7 +160,7 @@ RSpec.describe AlertManagement::Alerts::UpdateService do context 'when a status is included' do let(:params) { { status: new_status } } - let(:new_status) { AlertManagement::Alert::STATUSES[:acknowledged] } + let(:new_status) { :acknowledged } it 'successfully changes the status' do expect { response }.to change { alert.acknowledged? }.to(true) @@ -171,13 +171,13 @@ RSpec.describe AlertManagement::Alerts::UpdateService do it_behaves_like 'adds a system note' context 'with unknown status' do - let(:new_status) { -1 } + let(:new_status) { :unknown_status } it_behaves_like 'error response', 'Invalid status' end context 'with resolving status' do - let(:new_status) { AlertManagement::Alert::STATUSES[:resolved] } + let(:new_status) { :resolved } it 'changes the status' do expect { response }.to change { alert.resolved? }.to(true) diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb index d8e94a0885b..809b12910a1 100644 --- a/spec/services/projects/alerting/notify_service_spec.rb +++ b/spec/services/projects/alerting/notify_service_spec.rb @@ -62,7 +62,7 @@ RSpec.describe Projects::Alerting::NotifyService do title: payload_raw.fetch(:title), started_at: Time.zone.parse(payload_raw.fetch(:start_time)), severity: payload_raw.fetch(:severity), - status: AlertManagement::Alert::STATUSES[:triggered], + status: AlertManagement::Alert.status_value(:triggered), events: 1, hosts: payload_raw.fetch(:hosts), payload: payload_raw.with_indifferent_access, @@ -180,7 +180,7 @@ RSpec.describe Projects::Alerting::NotifyService do title: payload_raw.fetch(:title), started_at: Time.zone.parse(payload_raw.fetch(:start_time)), severity: 'critical', - status: AlertManagement::Alert::STATUSES[:triggered], + status: AlertManagement::Alert.status_value(:triggered), events: 1, hosts: [], payload: payload_raw.with_indifferent_access, diff --git a/spec/views/projects/tracing/show.html.haml_spec.rb b/spec/views/projects/tracing/show.html.haml_spec.rb new file mode 100644 index 00000000000..96dc6a18fc7 --- /dev/null +++ b/spec/views/projects/tracing/show.html.haml_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'projects/tracings/show' do + let_it_be_with_reload(:project) { create(:project) } + let_it_be(:error_tracking_setting) { create(:project_error_tracking_setting, project: project) } + + before do + assign(:project, project) + allow(view).to receive(:error_tracking_setting) + .and_return(error_tracking_setting) + end + + context 'with project.tracing_external_url' do + let_it_be(:tracing_url) { 'https://tracing.url' } + let_it_be(:tracing_setting) { create(:project_tracing_setting, project: project, external_url: tracing_url) } + + before do + allow(view).to receive(:can?).and_return(true) + allow(view).to receive(:tracing_setting).and_return(tracing_setting) + end + + it 'renders iframe' do + render + + expect(rendered).to match(/iframe/) + end + + context 'with malicious external_url' do + let(:malicious_tracing_url) { "https://replaceme.com/'><script>alert(document.cookie)</script>" } + let(:cleaned_url) { "https://replaceme.com/'>" } + + before do + tracing_setting.update_column(:external_url, malicious_tracing_url) + end + + it 'sanitizes external_url' do + render + + expect(tracing_setting.external_url).to eq(malicious_tracing_url) + expect(rendered).to have_xpath("//iframe[@src=\"#{cleaned_url}\"]") + end + end + end + + context 'without project.tracing_external_url' do + before do + allow(view).to receive(:can?).and_return(true) + end + + it 'renders empty state' do + render + + expect(rendered).to have_link('Add Jaeger URL') + expect(rendered).not_to match(/iframe/) + end + end +end |