diff options
121 files changed, 1349 insertions, 887 deletions
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue index f24c52f61da..9420480e35a 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -58,6 +58,11 @@ export default { required: false, default: false, }, + currentIntegration: { + type: Object, + required: false, + default: null, + }, }, fields: [ { @@ -82,17 +87,16 @@ export default { integrationToDelete: integrationToDeleteDefault, }; }, - computed: { - tbodyTrClass() { - return { - [bodyTrClass]: this.integrations.length, - }; - }, - }, mounted() { this.trackPageViews(); }, methods: { + tbodyTrClass(item) { + return { + [bodyTrClass]: this.integrations.length, + 'gl-bg-blue-50': item?.id === this.currentIntegration?.id, + }; + }, trackPageViews() { const { category, action } = trackAlertIntegrationsViewsOptions; Tracking.event(category, action); diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue index 946da8ef34c..a08100f3938 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue @@ -33,6 +33,9 @@ export default { step1: { label: s__('AlertSettings|1. Select integration type'), help: s__('AlertSettings|Learn more about our upcoming %{linkStart}integrations%{linkEnd}'), + enterprise: s__( + 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.', + ), }, step2: { label: s__('AlertSettings|2. Name integration'), @@ -107,6 +110,10 @@ export default { required: false, default: null, }, + canAddIntegration: { + type: Boolean, + required: true, + }, }, data() { return { @@ -236,15 +243,24 @@ export default { > <gl-form-select v-model="selectedIntegration" - :disabled="currentIntegration !== null" + :disabled="currentIntegration !== null || !canAddIntegration" :options="options" @change="integrationTypeSelect" /> - <alert-settings-form-help-block - :message="$options.i18n.integrationFormSteps.step1.help" - link="https://gitlab.com/groups/gitlab-org/-/epics/4390" - /> + <div class="gl-my-4"> + <alert-settings-form-help-block + :message="$options.i18n.integrationFormSteps.step1.help" + link="https://gitlab.com/groups/gitlab-org/-/epics/4390" + /> + </div> + + <div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported"> + <alert-settings-form-help-block + :message="$options.i18n.integrationFormSteps.step1.enterprise" + link="https://about.gitlab.com/pricing" + /> + </div> </gl-form-group> <gl-collapse v-model="formVisible" class="gl-mt-3"> <gl-form-group diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index e9e7b1407bc..57fc1984990 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -19,6 +19,12 @@ import { updateStoreAfterIntegrationDelete, updateStoreAfterIntegrationAdd, } from '../utils/cache_updates'; +import { + DELETE_INTEGRATION_ERROR, + ADD_INTEGRATION_ERROR, + RESET_INTEGRATION_TOKEN_ERROR, + UPDATE_INTEGRATION_ERROR, +} from '../utils/error_messages'; export default { typeSet, @@ -44,6 +50,9 @@ export default { projectPath: { default: '', }, + multiIntegrations: { + default: false, + }, }, apollo: { integrations: { @@ -91,6 +100,9 @@ export default { }, ]; }, + canAddIntegration() { + return this.multiIntegrations || this.integrations?.list?.length < 2; + }, }, methods: { createNewIntegration({ type, variables }) { @@ -121,8 +133,8 @@ export default { type: FLASH_TYPES.SUCCESS, }); }) - .catch(err => { - createFlash({ message: err }); + .catch(() => { + createFlash({ message: ADD_INTEGRATION_ERROR }); }) .finally(() => { this.isUpdating = false; @@ -151,8 +163,8 @@ export default { type: FLASH_TYPES.SUCCESS, }); }) - .catch(err => { - createFlash({ message: err }); + .catch(() => { + createFlash({ message: UPDATE_INTEGRATION_ERROR }); }) .finally(() => { this.isUpdating = false; @@ -187,8 +199,8 @@ export default { }); }, ) - .catch(err => { - createFlash({ message: err }); + .catch(() => { + createFlash({ message: RESET_INTEGRATION_TOKEN_ERROR }); }) .finally(() => { this.isUpdating = false; @@ -222,9 +234,8 @@ export default { type: FLASH_TYPES.SUCCESS, }); }) - .catch(err => { - this.errored = true; - createFlash({ message: err }); + .catch(() => { + createFlash({ message: DELETE_INTEGRATION_ERROR }); }) .finally(() => { this.isUpdating = false; @@ -242,6 +253,7 @@ export default { <integrations-list :integrations="glFeatures.httpIntegrationsList ? integrations.list : intergrationsOptionsOld" :loading="loading" + :current-integration="currentIntegration" @edit-integration="editIntegration" @delete-integration="deleteIntegration" /> @@ -249,6 +261,7 @@ export default { v-if="glFeatures.httpIntegrationsList" :loading="isUpdating" :current-integration="currentIntegration" + :can-add-integration="canAddIntegration" @create-new-integration="createNewIntegration" @update-integration="updateIntegration" @reset-token="resetToken" diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index 9cf2f356e0a..19eaccf05fc 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -57,15 +57,6 @@ export const typeSet = { prometheus: 'PROMETHEUS', }; -export const defaultFormState = { - name: '', - active: false, - token: '', - url: '', - apiUrl: '', - integrationTestPayload: { json: null, error: null }, -}; - export const integrationToDeleteDefault = { id: null, name: '' }; export const JSON_VALIDATE_DELAY = 250; diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index 2ae0dd447a1..a9d109b3f3e 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -29,19 +29,16 @@ export default el => { opsgenieMvcEnabled, opsgenieMvcTargetUrl, projectPath, + multiIntegrations, } = el.dataset; - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - cacheConfig: {}, - }, - ), - }); + const resolvers = {}; - apolloProvider.clients.defaultClient.cache.writeData({ - data: {}, + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers, { + cacheConfig: {}, + assumeImmutableResults: true, + }), }); return new Vue({ @@ -70,6 +67,7 @@ export default el => { opsgenieMvcIsAvailable: parseBoolean(opsgenieMvcAvailable), }, projectPath, + multiIntegrations: parseBoolean(multiIntegrations), }, apolloProvider, components: { diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js index 2e6058fc81a..7df5d444a53 100644 --- a/app/assets/javascripts/alerts_settings/utils/error_messages.js +++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js @@ -7,3 +7,11 @@ export const DELETE_INTEGRATION_ERROR = s__( export const ADD_INTEGRATION_ERROR = s__( 'AlertsIntegrations|The integration could not be added. Please try again.', ); + +export const UPDATE_INTEGRATION_ERROR = s__( + 'AlertsIntegrations|The current integration could not be updated. Please try again.', +); + +export const RESET_INTEGRATION_TOKEN_ERROR = s__( + 'AlertsIntegrations|The integration token could not be reset. Please try again.', +); diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index cb0e6345059..233c5f84340 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -25,7 +25,7 @@ function importMermaidModule() { return import(/* webpackChunkName: 'mermaid' */ 'mermaid') .then(mermaid => { let theme = 'neutral'; - const ideDarkThemes = ['dark', 'solarized-dark']; + const ideDarkThemes = ['dark', 'solarized-dark', 'monokai']; if ( ideDarkThemes.includes(window.gon?.user_color_scheme) && diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue index a04b1361d4e..b2c3b6aa8ab 100644 --- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -26,7 +26,7 @@ export default { data() { return { participants: [], - selected: this.$store.getters.getActiveIssue.assignees, + selected: this.$store.getters.activeIssue.assignees, }; }, apollo: { @@ -34,7 +34,7 @@ export default { query: getIssueParticipants, variables() { return { - id: `gid://gitlab/Issue/${this.getActiveIssue.iid}`, + id: `gid://gitlab/Issue/${this.activeIssue.iid}`, }; }, update(data) { @@ -43,7 +43,7 @@ export default { }, }, computed: { - ...mapGetters(['getActiveIssue']), + ...mapGetters(['activeIssue']), assigneeText() { return n__('Assignee', '%d Assignees', this.selected.length); }, @@ -88,7 +88,7 @@ export default { <template> <board-editable-item :title="assigneeText" @close="saveAssignees"> <template #collapsed> - <issuable-assignees :users="getActiveIssue.assignees" /> + <issuable-assignees :users="activeIssue.assignees" /> </template> <template #default> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 7a468abddf1..c8b713b0c5a 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -46,7 +46,7 @@ export default { }; }, computed: { - ...mapGetters(['getIssues']), + ...mapGetters(['getIssuesByList']), showBoardListAndBoardInfo() { return this.list.type !== ListType.promotion; }, @@ -58,7 +58,7 @@ export default { if (!this.glFeatures.graphqlBoardLists) { return this.list.issues; } - return this.getIssues(this.list.id); + return this.getIssuesByList(this.list.id); }, shouldFetchIssues() { return this.glFeatures.graphqlBoardLists && this.list.type !== ListType.blank; diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 06319df6ea5..80070b25bd0 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -69,14 +69,18 @@ export default { eventHub.$off('sidebar.closeAll', this.unsetActiveId); }, methods: { - ...mapActions(['unsetActiveId']), + ...mapActions(['unsetActiveId', 'removeList']), showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, deleteBoard() { // eslint-disable-next-line no-alert - if (window.confirm(__('Are you sure you want to delete this list?'))) { - this.activeList.destroy(); + if (window.confirm(__('Are you sure you want to remove this list?'))) { + if (this.shouldUseGraphQL) { + this.removeList(this.activeId); + } else { + this.activeList.destroy(); + } this.unsetActiveId(); } }, diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index c8926c5ef2a..47eee5306da 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -7,6 +7,7 @@ import { deprecatedCreateFlash as flash } from '~/flash'; import CreateLabelDropdown from '../../create_label'; import boardsStore from '../stores/boards_store'; import { fullLabelId } from '../boards_util'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; @@ -61,7 +62,7 @@ export default function initNewListDropdown() { const active = boardsStore.findListByLabelId(label.id); const $li = $('<li />'); const $a = $('<a />', { - class: active ? `is-active js-board-list-${active.id}` : '', + class: active ? `is-active js-board-list-${getIdFromGraphQLId(active.id)}` : '', text: label.title, href: '#', }); diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue index 97fe7572493..19e6f8a2269 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -18,7 +18,7 @@ export default { }; }, computed: { - ...mapGetters({ issue: 'getActiveIssue' }), + ...mapGetters({ issue: 'activeIssue' }), hasDueDate() { return this.issue.dueDate != null; }, diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 0f063c7582e..31094939733 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -21,7 +21,7 @@ export default { }, inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], computed: { - ...mapGetters({ issue: 'getActiveIssue' }), + ...mapGetters({ issue: 'activeIssue' }), selectedLabels() { const { labels = [] } = this.issue; diff --git a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql new file mode 100644 index 00000000000..ef3fd36e980 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql @@ -0,0 +1,5 @@ +mutation DestroyBoardList($listId: ID!) { + destroyBoardList(input: { listId: $listId }) { + errors + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index df5b84a974a..bbc7559cd86 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -18,6 +18,7 @@ import boardLabelsQuery from '../queries/board_labels.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'; +import destroyBoardListMutation from '../queries/board_list_destroy.mutation.graphql'; import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql'; @@ -212,8 +213,26 @@ export default { }); }, - deleteList: () => { - notImplemented(); + removeList: ({ state, commit }, listId) => { + const listsBackup = { ...state.boardLists }; + + commit(types.REMOVE_LIST, listId); + + return gqlClient + .mutate({ + mutation: destroyBoardListMutation, + variables: { + listId, + }, + }) + .then(({ data: { destroyBoardList: { errors } } }) => { + if (errors.length > 0) { + commit(types.REMOVE_LIST_FAILURE, listsBackup); + } + }) + .catch(() => { + commit(types.REMOVE_LIST_FAILURE, listsBackup); + }); }, fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { @@ -324,7 +343,7 @@ export default { }, setActiveIssueLabels: async ({ commit, getters }, input) => { - const activeIssue = getters.getActiveIssue; + const { activeIssue } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetLabels, variables: { @@ -349,7 +368,7 @@ export default { }, setActiveIssueDueDate: async ({ commit, getters }, input) => { - const activeIssue = getters.getActiveIssue; + const { activeIssue } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetDueDate, variables: { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index d1a5db1bcc5..337b2897fe9 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -1,7 +1,6 @@ /* eslint-disable no-shadow, no-param-reassign,consistent-return */ /* global List */ /* global ListIssue */ -import $ from 'jquery'; import { sortBy, pick } from 'lodash'; import Vue from 'vue'; import Cookies from 'js-cookie'; @@ -119,8 +118,12 @@ const boardsStore = { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); }, + updateNewListDropdown(listId) { - $(`.js-board-list-${listId}`).removeClass('is-active'); + // eslint-disable-next-line no-unused-expressions + document + .querySelector(`.js-board-list-${getIdFromGraphQLId(listId)}`) + ?.classList.remove('is-active'); }, shouldAddBlankState() { // Decide whether to add the blank state diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 89a3b14b262..f717b4101ab 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -2,7 +2,7 @@ import { find } from 'lodash'; import { inactiveId } from '../constants'; export default { - getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), + labelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), isSidebarOpen: state => state.activeId !== inactiveId, isSwimlanesOn: state => { if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) { @@ -15,12 +15,12 @@ export default { return state.issues[id] || {}; }, - getIssues: (state, getters) => listId => { + getIssuesByList: (state, getters) => listId => { const listIssueIds = state.issuesByListId[listId] || []; return listIssueIds.map(id => getters.getIssueById(id)); }, - getActiveIssue: state => { + activeIssue: state => { return state.issues[state.activeId] || {}; }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index d5ddba710a9..29468105b5c 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -12,9 +12,8 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR'; export const MOVE_LIST = 'MOVE_LIST'; 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 REMOVE_LIST = 'REMOVE_LIST'; +export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; 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'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 361bb94abe0..eb2003a6ed3 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -93,16 +93,13 @@ export default { Vue.set(state, 'boardLists', backupList); }, - [mutationTypes.REQUEST_REMOVE_LIST]: () => { - notImplemented(); + [mutationTypes.REMOVE_LIST]: (state, listId) => { + Vue.delete(state.boardLists, listId); }, - [mutationTypes.RECEIVE_REMOVE_LIST_SUCCESS]: () => { - notImplemented(); - }, - - [mutationTypes.RECEIVE_REMOVE_LIST_ERROR]: () => { - notImplemented(); + [mutationTypes.REMOVE_LIST_FAILURE](state, listsBackup) { + state.error = s__('Boards|An error occurred while removing the list. Please try again.'); + state.boardLists = listsBackup; }, [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index 7a81d5963f2..5487aeb9391 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -7,7 +7,7 @@ * @returns {Number} */ export const getIdFromGraphQLId = (gid = '') => - parseInt((gid || '').replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null; + parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null; export const MutationOperationMode = { Append: 'APPEND', diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 54365df2119..e1d2895831a 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,6 +1,5 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { WEBIDE_MARK_APP_START, @@ -14,15 +13,8 @@ import { import { performanceMarkAndMeasure } from '~/performance/utils'; import { modalTypes } from '../constants'; import eventHub from '../eventhub'; -import FindFile from '~/vue_shared/components/file_finder/index.vue'; -import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; -import RepoTabs from './repo_tabs.vue'; -import IdeStatusBar from './ide_status_bar.vue'; import RepoEditor from './repo_editor.vue'; -import RightPane from './panes/right.vue'; -import ErrorMessage from './error_message.vue'; -import CommitEditorHeader from './commit_sidebar/editor_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { measurePerformance } from '../utils'; @@ -43,19 +35,24 @@ eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () => export default { components: { - NewModal, IdeSidebar, - RepoTabs, - IdeStatusBar, RepoEditor, - FindFile, - ErrorMessage, - CommitEditorHeader, - GlButton, - GlLoadingIcon, - RightPane, + 'error-message': () => import('./error_message.vue'), + 'gl-button': () => import('@gitlab/ui/src/components/base/button/button.vue'), + 'gl-loading-icon': () => import('@gitlab/ui/src/components/base/loading_icon/loading_icon.vue'), + 'commit-editor-header': () => import('./commit_sidebar/editor_header.vue'), + 'repo-tabs': () => import('./repo_tabs.vue'), + 'ide-status-bar': () => import('./ide_status_bar.vue'), + 'find-file': () => import('~/vue_shared/components/file_finder/index.vue'), + 'right-pane': () => import('./panes/right.vue'), + 'new-modal': () => import('./new_dropdown/modal.vue'), }, mixins: [glFeatureFlagsMixin()], + data() { + return { + loadDeferred: false, + }; + }, computed: { ...mapState([ 'openFiles', @@ -107,6 +104,9 @@ export default { createNewFile() { this.$refs.newModal.open(modalTypes.blob); }, + loadDeferredComponents() { + this.loadDeferred = true; + }, }, }; </script> @@ -118,19 +118,23 @@ export default { > <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> - <find-file - v-show="fileFindVisible" - :files="allBlobs" - :visible="fileFindVisible" - :loading="loading" - @toggle="toggleFileFinder" - @click="openFile" - /> - <ide-sidebar /> + <template v-if="loadDeferred"> + <find-file + v-show="fileFindVisible" + :files="allBlobs" + :visible="fileFindVisible" + :loading="loading" + @toggle="toggleFileFinder" + @click="openFile" + /> + </template> + <ide-sidebar @tree-ready="loadDeferredComponents" /> <div class="multi-file-edit-pane"> <template v-if="activeFile"> - <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" /> - <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" /> + <template v-if="loadDeferred"> + <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" /> + <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" /> + </template> <repo-editor :file="activeFile" class="multi-file-edit-pane-content" /> </template> <template v-else> @@ -177,9 +181,13 @@ export default { </div> </template> </div> - <right-pane v-if="currentProjectId" /> + <template v-if="loadDeferred"> + <right-pane v-if="currentProjectId" /> + </template> </div> - <ide-status-bar /> - <new-modal ref="newModal" /> + <template v-if="loadDeferred"> + <ide-status-bar /> + <new-modal ref="newModal" /> + </template> </article> </template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 53dfc133fc8..99215d6c3f1 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -4,21 +4,19 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import IdeTree from './ide_tree.vue'; import ResizablePanel from './resizable_panel.vue'; import ActivityBar from './activity_bar.vue'; -import RepoCommitSection from './repo_commit_section.vue'; import CommitForm from './commit_sidebar/form.vue'; -import IdeReview from './ide_review.vue'; import IdeProjectHeader from './ide_project_header.vue'; -import { SIDEBAR_INIT_WIDTH } from '../constants'; +import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants'; export default { components: { GlSkeletonLoading, ResizablePanel, ActivityBar, - RepoCommitSection, IdeTree, + [leftSidebarViews.review.name]: () => import('./ide_review.vue'), + [leftSidebarViews.commit.name]: () => import('./repo_commit_section.vue'), CommitForm, - IdeReview, IdeProjectHeader, }, computed: { @@ -49,7 +47,7 @@ export default { <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner"> <div class="multi-file-commit-panel-inner-content"> <keep-alive> - <component :is="currentActivityView" /> + <component :is="currentActivityView" @tree-ready="$emit('tree-ready')" /> </keep-alive> </div> <commit-form /> diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 56fcb6c2600..e563de6659a 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -51,7 +51,7 @@ export default { </script> <template> - <ide-tree-list> + <ide-tree-list @tree-ready="$emit('tree-ready')"> <template #header> {{ __('Edit') }} <div class="ide-tree-actions ml-auto d-flex" data-testid="ide-root-actions"> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index d7ff1b8316f..e7e94f5b5da 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -32,6 +32,13 @@ export default { return !this.currentTree || this.currentTree.loading; }, }, + watch: { + showLoading(newVal) { + if (!newVal) { + this.$emit('tree-ready'); + } + }, + }, beforeCreate() { performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START }); }, diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 96cb024c768..61e5db0970a 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -136,6 +136,16 @@ export default { type: String, required: true, }, + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + isLocked: { + type: Boolean, + required: false, + default: false, + }, issuableType: { type: String, required: false, @@ -453,6 +463,12 @@ export default { <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> <span class="gl-display-none d-sm-block">{{ statusText }}</span> </p> + <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon"> + <gl-icon name="lock" :aria-label="__('Locked')" /> + </span> + <span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon"> + <gl-icon name="eye-slash" :aria-label="__('Confidential')" /> + </span> <p class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" :title="state.titleText" diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issue_show/issue.js index fc9e8e051bb..b5cd466596e 100644 --- a/app/assets/javascripts/issue_show/issue.js +++ b/app/assets/javascripts/issue_show/issue.js @@ -17,6 +17,8 @@ export function initIssuableApp(issuableData, store) { return createElement(IssuableApp, { props: { ...issuableData, + isConfidential: this.getNoteableData?.confidential, + isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, }, }); diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 878a748e99a..0272790a75d 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -45,7 +45,7 @@ export default { return this.discussion.notes.filter(x => x.resolvable); }, userCanResolveDiscussion() { - return this.resolvableNotes.every(note => note.current_user && note.current_user.can_resolve); + return this.resolvableNotes.every(note => note.current_user?.can_resolve_discussion); }, }, }; diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 4b3f23e742d..43f17c5d65c 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -121,7 +121,13 @@ export default { return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit; }, showResolveDiscussionToggle() { - return (this.discussion?.id && this.discussion.resolvable) || this.isDraft; + if (!this.discussion?.notes) return false; + + return ( + this.discussion?.notes + .filter(n => n.resolvable) + .some(n => n.current_user?.can_resolve_discussion) || this.isDraft + ); }, noteHash() { if (this.noteId) { diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 1775e83978e..9be53fe60f2 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -141,6 +141,8 @@ export default { canResolve() { if (this.glFeatures.removeResolveNote && !this.discussionRoot) return false; + if (this.glFeatures.removeResolveNote) return this.note.current_user.can_resolve_discussion; + return ( this.note.current_user.can_resolve || (this.note.isDraft && this.note.discussion_id !== null) diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 09f40601fbf..4ed0aae0d1e 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -132,24 +132,26 @@ export default { <div class="ci-job-component" data-qa-selector="job_item_container"> <gl-link v-if="status.has_details" - v-gl-tooltip="{ boundary, placement: 'bottom' }" + v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" :href="status.details_path" :title="tooltipText" :class="jobClasses" class="js-pipeline-graph-job-link qa-job-link menu-item" data-testid="job-with-link" @click.stop="hideTooltips" + @mouseout="hideTooltips" > <job-name-component :name="job.name" :status="job.status" /> </gl-link> <div v-else - v-gl-tooltip="{ boundary, placement: 'bottom' }" + v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" :title="tooltipText" :class="jobClasses" class="js-job-component-tooltip non-details-job-component" data-testid="job-without-link" + @mouseout="hideTooltips" > <job-name-component :name="job.name" :status="job.status" /> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 00ccf59e37c..9ee427d01fd 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -337,7 +337,7 @@ export default { :message="emptyTabMessage" /> - <div v-else-if="stateToRender === $options.stateMap.tableList" class="table-holder"> + <div v-else-if="stateToRender === $options.stateMap.tableList"> <pipelines-table-component :pipelines="state.pipelines" :pipeline-schedule-url="pipelineScheduleUrl" diff --git a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue deleted file mode 100644 index 08619fa2066..00000000000 --- a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue +++ /dev/null @@ -1,108 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; -import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; -import { sprintf, s__ } from '~/locale'; - -export default { - name: 'DropdownFilter', - components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - }, - props: { - filterData: { - type: Object, - required: true, - }, - }, - computed: { - ...mapState(['query']), - scope() { - return this.query.scope; - }, - supportedScopes() { - return Object.values(this.filterData.scopes); - }, - initialFilter() { - return this.query[this.filterData.filterParam]; - }, - filter() { - return this.initialFilter || this.filterData.filters.ANY.value; - }, - filtersArray() { - return this.filterData.filterByScope[this.scope]; - }, - selectedFilter: { - get() { - if (this.filtersArray.some(({ value }) => value === this.filter)) { - return this.filter; - } - - return this.filterData.filters.ANY.value; - }, - set(filter) { - // we need to remove the pagination cursor to ensure the - // relevancy of the new results - - visitUrl( - setUrlParams({ - page: null, - [this.filterData.filterParam]: filter, - }), - ); - }, - }, - selectedFilterText() { - const f = this.filtersArray.find(({ value }) => value === this.selectedFilter); - if (!f || f === this.filterData.filters.ANY) { - return sprintf(s__('Any %{header}'), { header: this.filterData.header }); - } - - return f.label; - }, - showDropdown() { - return this.supportedScopes.includes(this.scope); - }, - }, - methods: { - dropDownItemClass(filter) { - return { - 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2': - filter === this.filterData.filters.ANY, - }; - }, - isFilterSelected(filter) { - return filter === this.selectedFilter; - }, - handleFilterChange(filter) { - this.selectedFilter = filter; - }, - }, -}; -</script> - -<template> - <gl-dropdown - v-if="showDropdown" - :text="selectedFilterText" - class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4" - menu-class="gl-w-full! gl-pl-0" - > - <header class="gl-text-center gl-font-weight-bold gl-font-lg"> - {{ filterData.header }} - </header> - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="f in filtersArray" - :key="f.value" - :is-check-item="true" - :is-checked="isFilterSelected(f.value)" - :class="dropDownItemClass(f)" - @click="handleFilterChange(f.value)" - > - {{ f.label }} - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js b/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js deleted file mode 100644 index b29daca89cb..00000000000 --- a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js +++ /dev/null @@ -1,36 +0,0 @@ -import { __ } from '~/locale'; - -const header = __('Confidentiality'); - -const filters = { - ANY: { - label: __('Any'), - value: null, - }, - CONFIDENTIAL: { - label: __('Confidential'), - value: 'yes', - }, - NOT_CONFIDENTIAL: { - label: __('Not confidential'), - value: 'no', - }, -}; - -const scopes = { - ISSUES: 'issues', -}; - -const filterByScope = { - [scopes.ISSUES]: [filters.ANY, filters.CONFIDENTIAL, filters.NOT_CONFIDENTIAL], -}; - -const filterParam = 'confidential'; - -export default { - header, - filters, - scopes, - filterByScope, - filterParam, -}; diff --git a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js b/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js deleted file mode 100644 index 0b93aa0be29..00000000000 --- a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js +++ /dev/null @@ -1,42 +0,0 @@ -import { __ } from '~/locale'; - -const header = __('Status'); - -const filters = { - ANY: { - label: __('Any'), - value: 'all', - }, - OPEN: { - label: __('Open'), - value: 'opened', - }, - CLOSED: { - label: __('Closed'), - value: 'closed', - }, - MERGED: { - label: __('Merged'), - value: 'merged', - }, -}; - -const scopes = { - ISSUES: 'issues', - MERGE_REQUESTS: 'merge_requests', -}; - -const filterByScope = { - [scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED], - [scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED], -}; - -const filterParam = 'state'; - -export default { - header, - filters, - scopes, - filterByScope, - filterParam, -}; diff --git a/app/assets/javascripts/search/dropdown_filter/index.js b/app/assets/javascripts/search/dropdown_filter/index.js deleted file mode 100644 index e5e0745d990..00000000000 --- a/app/assets/javascripts/search/dropdown_filter/index.js +++ /dev/null @@ -1,38 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import DropdownFilter from './components/dropdown_filter.vue'; -import stateFilterData from './constants/state_filter_data'; -import confidentialFilterData from './constants/confidential_filter_data'; - -Vue.use(Translate); - -const mountDropdownFilter = (store, { id, filterData }) => { - const el = document.getElementById(id); - - if (!el) return false; - - return new Vue({ - el, - store, - render(createElement) { - return createElement(DropdownFilter, { - props: { - filterData, - }, - }); - }, - }); -}; - -const dropdownFilters = [ - { - id: 'js-search-filter-by-state', - filterData: stateFilterData, - }, - { - id: 'js-search-filter-by-confidential', - filterData: confidentialFilterData, - }, -]; - -export default store => [...dropdownFilters].map(filter => mountDropdownFilter(store, filter)); diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index 275d6351adc..7508b3c9a55 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,17 +1,11 @@ import { queryToObject } from '~/lib/utils/url_utility'; import createStore from './store'; -import initDropdownFilters from './dropdown_filter'; import { initSidebar } from './sidebar'; import initGroupFilter from './group_filter'; export default () => { const store = createStore({ query: queryToObject(window.location.search) }); - if (gon.features.searchFacets) { - initSidebar(store); - } else { - initDropdownFilters(store); - } - + initSidebar(store); initGroupFilter(store); }; diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue new file mode 100644 index 00000000000..0c50f93d381 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -0,0 +1,41 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlButton, GlLink } from '@gitlab/ui'; +import StatusFilter from './status_filter.vue'; +import ConfidentialityFilter from './confidentiality_filter.vue'; + +export default { + name: 'GlobalSearchSidebar', + components: { + GlButton, + GlLink, + StatusFilter, + ConfidentialityFilter, + }, + computed: { + ...mapState(['query']), + showReset() { + return this.query.state || this.query.confidential; + }, + }, + methods: { + ...mapActions(['applyQuery', 'resetQuery']), + }, +}; +</script> + +<template> + <form + class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mb gl-mt-5" + @submit.prevent="applyQuery" + > + <status-filter /> + <confidentiality-filter /> + <div class="gl-display-flex gl-align-items-center gl-mt-3"> + <gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button> + <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{ + __('Reset filters') + }}</gl-link> + </div> + </form> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index f8938e799aa..38dccb9675d 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -21,5 +21,6 @@ export default { <template> <div v-if="showDropdown"> <radio-filter :filter-data="$options.confidentialFilterData" /> + <hr class="gl-my-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index 876123ccc52..5cec2090906 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -21,5 +21,6 @@ export default { <template> <div v-if="showDropdown"> <radio-filter :filter-data="$options.stateFilterData" /> + <hr class="gl-my-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js index b19016edf3d..6419e8ac2c6 100644 --- a/app/assets/javascripts/search/sidebar/index.js +++ b/app/assets/javascripts/search/sidebar/index.js @@ -1,12 +1,11 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import StatusFilter from './components/status_filter.vue'; -import ConfidentialityFilter from './components/confidentiality_filter.vue'; +import GlobalSearchSidebar from './components/app.vue'; Vue.use(Translate); -const mountRadioFilters = (store, { id, component }) => { - const el = document.getElementById(id); +export const initSidebar = store => { + const el = document.getElementById('js-search-sidebar'); if (!el) return false; @@ -14,21 +13,7 @@ const mountRadioFilters = (store, { id, component }) => { el, store, render(createElement) { - return createElement(component); + return createElement(GlobalSearchSidebar); }, }); }; - -const radioFilters = [ - { - id: 'js-search-filter-by-state', - component: StatusFilter, - }, - { - id: 'js-search-filter-by-confidential', - component: ConfidentialityFilter, - }, -]; - -export const initSidebar = store => - [...radioFilters].map(filter => mountRadioFilters(store, filter)); diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 722ed2eec26..447278aa223 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -1,6 +1,7 @@ import Api from '~/api'; import createFlash from '~/flash'; import { __ } from '~/locale'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import * as types from './mutation_types'; export const fetchGroups = ({ commit }, search) => { @@ -18,3 +19,11 @@ export const fetchGroups = ({ commit }, search) => { export const setQuery = ({ commit }, { key, value }) => { commit(types.SET_QUERY, { key, value }); }; + +export const applyQuery = ({ state }) => { + visitUrl(setUrlParams({ ...state.query, page: null })); +}; + +export const resetQuery = ({ state }) => { + visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null })); +}; diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index 034c8dea012..09bc176c399 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -37,6 +37,14 @@ export default { required: false, default: '', }, + branch: { + type: String, + required: true, + }, + baseUrl: { + type: String, + required: true, + }, mounts: { type: Array, required: true, @@ -75,7 +83,7 @@ export default { return this.editorMode === EDITOR_TYPES.wysiwyg; }, customRenderers() { - const imageRenderer = renderImage.build(this.mounts, this.project); + const imageRenderer = renderImage.build(this.mounts, this.project, this.branch, this.baseUrl); return { image: [imageRenderer], }; diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql index 4842dfcda49..e422a4b6036 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql @@ -6,6 +6,8 @@ query appData { sourcePath username returnUrl + branch + baseUrl mounts { source target diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql index fdac87cd91c..00af6c10359 100644 --- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql @@ -26,6 +26,8 @@ type AppData { returnUrl: String sourcePath: String! username: String! + branch: String! + baseUrl: String! mounts: [Mount]! imageUploadPath: String! } diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index dd684429794..b58564388de 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -9,6 +9,7 @@ const initStaticSiteEditor = el => { isSupportedContent, path: sourcePath, baseUrl, + branch, namespace, project, mergeRequestsIllustrationPath, @@ -27,6 +28,8 @@ const initStaticSiteEditor = el => { hasSubmittedChanges: false, project: `${namespace}/${project}`, mounts: JSON.parse(mounts), // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object. + branch, + baseUrl, returnUrl, sourcePath, username, diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 3e2e6e3b3ee..b61e0968d7d 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -139,6 +139,8 @@ export default { :saving-changes="isSavingChanges" :return-url="appData.returnUrl" :mounts="appData.mounts" + :branch="appData.branch" + :base-url="appData.baseUrl" :project="appData.project" :image-root="appData.imageUploadPath" @submit="onPrepareSubmit" diff --git a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js index 38304a1c57f..5e5e5d1c860 100644 --- a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js +++ b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js @@ -1,29 +1,80 @@ +import { isAbsolute, getBaseURL, joinPaths } from '~/lib/utils/url_utility'; + const canRender = ({ type }) => type === 'image'; -// NOTE: the `metadata` is not used yet, but will be used in a follow-up iteration -// To be removed with the next iteration of https://gitlab.com/gitlab-org/gitlab/-/issues/218531 -// eslint-disable-next-line no-unused-vars let metadata; -const render = (node, { skipChildren }) => { - skipChildren(); +const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/'); + +const extractSourceDirectory = url => { + const sourceDir = /^(.+)\/([^/]+)$/.exec(url); // Extracts the base path and fileName from an image path + return sourceDir || [null, null, url]; // If no source directory was extracted it means only a fileName was specified (e.g. url='file.png') +}; + +const parseCurrentDirectory = basePath => { + const baseUrl = decodeURIComponent(metadata.baseUrl); + const sourceDirectory = extractSourceDirectory(baseUrl)[1]; + const currentDirectory = sourceDirectory.split(`/-/sse/${metadata.branch}`)[1]; + + return joinPaths(currentDirectory, basePath); +}; + +// For more context around this logic, please see the following comment: +// https://gitlab.com/gitlab-org/gitlab/-/issues/241166#note_409413500 +const generateSourceDirectory = basePath => { + let sourceDir = ''; + let defaultSourceDir = ''; + + if (!basePath || isRelativeToCurrentDirectory(basePath)) { + return parseCurrentDirectory(basePath); + } + + if (!metadata.mounts.length) { + return basePath; + } - // To be removed with the next iteration of https://gitlab.com/gitlab-org/gitlab/-/issues/218531 - // TODO resolve relative paths + metadata.mounts.forEach(({ source, target }) => { + const hasTarget = target !== ''; + + if (hasTarget && basePath.includes(target)) { + sourceDir = source; + } else if (!hasTarget) { + defaultSourceDir = joinPaths(source, basePath); + } + }); + + return sourceDir || defaultSourceDir; +}; + +const resolveFullPath = originalSrc => { + if (isAbsolute(originalSrc)) { + return originalSrc; + } + + const sourceDirectory = extractSourceDirectory(originalSrc); + const [, basePath, fileName] = sourceDirectory; + const sourceDir = generateSourceDirectory(basePath); + + return joinPaths(getBaseURL(), metadata.project, '/-/raw/', metadata.branch, sourceDir, fileName); +}; + +const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => { + skipChildren(); return { type: 'openTag', tagName: 'img', selfClose: true, attributes: { - src: node.destination, - alt: node.firstChild.literal, + 'data-original-src': !isAbsolute(originalSrc) ? originalSrc : '', + src: resolveFullPath(originalSrc), + alt: firstChild.literal, }, }; }; -const build = (mounts, project) => { - metadata = { mounts, project }; +const build = (mounts = [], project, branch, baseUrl) => { + metadata = { mounts, project, branch, baseUrl }; return { canRender, render }; }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js index 2bce691e793..9744e25a8e1 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js @@ -99,6 +99,10 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => ? `\n\n${node.innerText}\n\n` : baseRenderer.convert(node, subContent); }, + IMG(node) { + const { originalSrc } = node.dataset; + return `![${node.alt}](${originalSrc || node.src})`; + }, }; }; diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index 14274c0747a..52cc7d3449e 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -151,7 +151,7 @@ border-color: var(--ide-border-color-alt, $gray-100); code { - background-color: var(--ide-border-color, inherit); + background-color: var(--ide-empty-state-background, inherit); } } @@ -427,7 +427,7 @@ } .md table:not(.code) tbody { - background-color: var(--ide-border-color, $white); + background-color: var(--ide-empty-state-background, $white); } .animation-container { diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 54cd267b993..15cc10d1532 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -5,7 +5,9 @@ @import './ide_theme_overrides'; @import './ide_themes/dark'; +@import './ide_themes/solarized-light'; @import './ide_themes/solarized-dark'; +@import './ide_themes/monokai'; $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; @@ -176,11 +178,11 @@ $ide-commit-header-height: 48px; height: 100%; overflow: auto; padding: $gl-padding; - background-color: var(--ide-border-color, transparent); + background-color: var(--ide-empty-state-background, transparent); } .file-container { - background-color: var(--ide-border-color, $gray-darker); + background-color: var(--ide-empty-state-background, $gray-darker); display: flex; height: 100%; align-items: center; @@ -491,7 +493,7 @@ $ide-commit-header-height: 48px; height: 100vh; align-items: center; justify-content: center; - background-color: var(--ide-border-color, transparent); + background-color: var(--ide-empty-state-background, transparent); } .ide { diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss index 5f31d554c19..c7aae77c412 100644 --- a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss +++ b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss @@ -12,6 +12,7 @@ --ide-highlight-background: #252526; --ide-link-color: #428fdc; --ide-footer-background: #060606; + --ide-empty-state-background: var(--ide-border-color); --ide-input-border: #868686; --ide-input-background: transparent; diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss b/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss new file mode 100644 index 00000000000..f53ace0b6c2 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss @@ -0,0 +1,66 @@ +// ------- +// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes +// ------- +.ide.theme-monokai { + --ide-border-color: #1a1a18; + --ide-border-color-alt: #3f4237; + --ide-highlight-accent: #fff; + --ide-text-color: #ccc; + --ide-text-color-secondary: #b7b7b7; + --ide-background: #282822; + --ide-background-hover: #2d2d2d; + --ide-highlight-background: #1f1f1d; + --ide-link-color: #428fdc; + --ide-footer-background: #404338; + --ide-empty-state-background: #1a1a18; + + --ide-input-border: #7d8175; + --ide-input-background: transparent; + --ide-input-color: #fff; + + --ide-btn-default-background: transparent; + --ide-btn-default-border: #7d8175; + --ide-btn-default-hover-border: #b5bda5; + --ide-btn-default-hover-border-width: 2px; + --ide-btn-default-focus-box-shadow: 0 0 0 1px #bfbfbf; + + --ide-btn-primary-background: #1068bf; + --ide-btn-primary-border: #428fdc; + --ide-btn-primary-hover-border: #63a6e9; + --ide-btn-primary-hover-border-width: 2px; + --ide-btn-primary-focus-box-shadow: 0 0 0 1px #63a6e9; + + --ide-btn-success-background: #217645; + --ide-btn-success-border: #108548; + --ide-btn-success-hover-border: #2da160; + --ide-btn-success-hover-border-width: 2px; + --ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160; + + // Danger styles should be the same as default styles in dark theme + --ide-btn-danger-secondary-background: var(--ide-btn-default-background); + --ide-btn-danger-secondary-border: var(--ide-btn-default-border); + --ide-btn-danger-secondary-hover-border: var(--ide-btn-default-hover-border); + --ide-btn-danger-secondary-hover-border-width: var(--ide-btn-default-hover-border-width); + --ide-btn-danger-secondary-focus-box-shadow: var(--ide-btn-default-focus-box-shadow); + + --ide-btn-disabled-background: transparent; + --ide-btn-disabled-border: rgba(223, 223, 223, 0.24); + --ide-btn-disabled-hover-border: rgba(223, 223, 223, 0.24); + --ide-btn-disabled-hover-border-width: 1px; + --ide-btn-disabled-focus-box-shadow: 0 0 0 0 transparent; + --ide-btn-disabled-color: rgba(145, 145, 145, 0.48); + + --ide-dropdown-background: #36382f; + --ide-dropdown-hover-background: #404338; + + --ide-dropdown-btn-hover-border: #b5bda5; + --ide-dropdown-btn-hover-background: #3f4237; + + --ide-file-row-btn-hover-background: #404338; + + --ide-diff-insert: rgba(155, 185, 85, 0.2); + --ide-diff-remove: rgba(255, 0, 0, 0.2); + + --ide-animation-gradient-1: #404338; + --ide-animation-gradient-2: #36382f; +} diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss index 83c55310063..1906b3ca938 100644 --- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss +++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss @@ -12,6 +12,7 @@ --ide-highlight-background: #003240; --ide-link-color: #73b9ff; --ide-footer-background: var(--ide-highlight-background); + --ide-empty-state-background: var(--ide-border-color); --ide-input-border: #d8d8d8; --ide-input-background: transparent; diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss new file mode 100644 index 00000000000..315a0ae6202 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss @@ -0,0 +1,57 @@ +// ------- +// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes +// ------- +.ide.theme-solarized-light { + --ide-border-color: #dfd7bf; + --ide-border-color-alt: #dfd7bf; + --ide-highlight-accent: #5c4e21; + --ide-text-color: #616161; + --ide-text-color-secondary: #526f76; + --ide-background: #efe8d3; + --ide-background-hover: #ded6be; + --ide-highlight-background: #fef6e1; + --ide-link-color: #955800; + --ide-footer-background: #efe8d3; + --ide-empty-state-background: #fef6e1; + + --ide-input-border: #c0b9a4; + --ide-input-background: transparent; + + --ide-btn-default-background: transparent; + --ide-btn-default-border: #c0b9a4; + --ide-btn-default-hover-border: #c0b9a4; + + --ide-btn-primary-background: #b16802; + --ide-btn-primary-border: #a35f00; + --ide-btn-primary-hover-border: #955800; + --ide-btn-primary-hover-border-width: 2px; + --ide-btn-primary-focus-box-shadow: 0 0 0 1px #dd8101; + + --ide-btn-danger-secondary-background: transparent; + + --ide-btn-disabled-background: transparent; + --ide-btn-disabled-border: rgba(192, 185, 64, 0.48); + --ide-btn-disabled-hover-border: rgba(192, 185, 64, 0.48); + --ide-btn-disabled-hover-border-width: 1px; + --ide-btn-disabled-focus-box-shadow: transparent; + --ide-btn-disabled-color: rgba(82, 82, 82, 0.48); + + --ide-dropdown-background: #fef6e1; + --ide-dropdown-hover-background: #efe8d3; + + --ide-dropdown-btn-hover-border: #dfd7bf; + --ide-dropdown-btn-hover-background: #efe8d3; + + --ide-file-row-btn-hover-background: #ded6be; + + --ide-animation-gradient-1: #d3cbb3; + --ide-animation-gradient-2: #efe8d3; + + .ide-empty-state, + .ide-sidebar, + .ide-commit-empty-state { + img { + filter: sepia(1) brightness(0.7); + } + } +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 00d32b75628..cc4827f75d4 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -1,16 +1,12 @@ .issuable-warning-icon { background-color: $orange-50; border-radius: $border-radius-default; + color: $orange-600; width: $issuable-warning-size; height: $issuable-warning-size; text-align: center; margin-right: $issuable-warning-icon-margin; line-height: $gl-line-height-24; - - .icon { - fill: $orange-600; - vertical-align: text-bottom; - } } .limit-container-width { diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index b005347c43a..a45205c5da7 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -9,9 +9,13 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController end def create - @personal_access_token = finder.build(personal_access_token_params) + result = ::PersonalAccessTokens::CreateService.new( + current_user: current_user, target_user: current_user, params: personal_access_token_params + ).execute - if @personal_access_token.save + @personal_access_token = result.payload[:personal_access_token] + + if result.success? PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token) redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.") else diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 0f149c24a59..4b21edc98d5 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -24,10 +24,6 @@ class SearchController < ApplicationController search_term_present && !params[:project_id].present? end - before_action do - push_frontend_feature_flag(:search_facets) - end - layout 'search' feature_category :global_search diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb index 3e802156c8f..8105fce10cf 100644 --- a/app/helpers/operations_helper.rb +++ b/app/helpers/operations_helper.rb @@ -30,7 +30,8 @@ module OperationsHelper 'alerts_setup_url' => help_page_path('operations/incident_management/alert_integrations.md', anchor: 'generic-http-endpoint'), 'alerts_usage_url' => project_alert_management_index_path(@project), 'disabled' => disabled.to_s, - 'project_path' => @project.full_path + 'project_path' => @project.full_path, + 'multi_integrations' => 'false' } end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 61030b6d5e6..70e8fb32064 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -27,6 +27,7 @@ class UserPolicy < BasePolicy rule { default }.enable :read_user_profile rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile rule { user_is_self | admin }.enable :disable_two_factor + rule { (user_is_self | admin) & ~blocked }.enable :create_user_personal_access_token end UserPolicy.prepend_if_ee('EE::UserPolicy') diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index ef305195e22..cf7a71a11d0 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -34,6 +34,10 @@ class NoteEntity < API::Entities::Note expose :can_resolve do |note| note.resolvable? && can?(current_user, :resolve_note, note) end + + expose :can_resolve_discussion do |note| + note.discussion.resolvable? && note.discussion.can_resolve?(current_user) + end end expose :suggestions, using: SuggestionEntity diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb index ff9bb7d6802..93a0135669f 100644 --- a/app/services/personal_access_tokens/create_service.rb +++ b/app/services/personal_access_tokens/create_service.rb @@ -2,23 +2,30 @@ module PersonalAccessTokens class CreateService < BaseService - def initialize(current_user, params = {}) + def initialize(current_user:, target_user:, params: {}) @current_user = current_user + @target_user = target_user @params = params.dup + @ip_address = @params.delete(:ip_address) end def execute - personal_access_token = current_user.personal_access_tokens.create(params.slice(*allowed_params)) + return ServiceResponse.error(message: 'Not permitted to create') unless creation_permitted? - if personal_access_token.persisted? - ServiceResponse.success(payload: { personal_access_token: personal_access_token }) + token = target_user.personal_access_tokens.create(params.slice(*allowed_params)) + + if token.persisted? + log_event(token) + ServiceResponse.success(payload: { personal_access_token: token }) else - ServiceResponse.error(message: personal_access_token.errors.full_messages.to_sentence) + ServiceResponse.error(message: token.errors.full_messages.to_sentence, payload: { personal_access_token: token }) end end private + attr_reader :target_user, :ip_address + def allowed_params [ :name, @@ -27,5 +34,15 @@ module PersonalAccessTokens :expires_at ] end + + def creation_permitted? + Ability.allowed?(current_user, :create_user_personal_access_token, target_user) + end + + def log_event(token) + log_info("PAT CREATION: created_by: '#{current_user.username}', created_for: '#{token.user.username}', token_id: '#{token.id}'") + end end end + +PersonalAccessTokens::CreateService.prepend_if_ee('EE::PersonalAccessTokens::CreateService') diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb index 17405002d8d..34d542acab1 100644 --- a/app/services/personal_access_tokens/revoke_service.rb +++ b/app/services/personal_access_tokens/revoke_service.rb @@ -4,16 +4,17 @@ module PersonalAccessTokens class RevokeService attr_reader :token, :current_user, :group - def initialize(current_user = nil, params = { token: nil, group: nil }) + def initialize(current_user = nil, token: nil, group: nil ) @current_user = current_user - @token = params[:token] - @group = params[:group] + @token = token + @group = group end def execute return ServiceResponse.error(message: 'Not permitted to revoke') unless revocation_permitted? if token.revoke! + log_event ServiceResponse.success(message: success_message) else ServiceResponse.error(message: error_message) @@ -33,6 +34,10 @@ module PersonalAccessTokens def revocation_permitted? Ability.allowed?(current_user, :revoke_token, token) end + + def log_event + Gitlab::AppLogger.info("PAT REVOCATION: revoked_by: '#{current_user.username}', revoked_for: '#{token.user.username}', token_id: '#{token.id}'") + end end end diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index c2b4773a505..70e09be9407 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -83,7 +83,9 @@ module ResourceAccessTokens end def create_personal_access_token(user) - PersonalAccessTokens::CreateService.new(user, personal_access_token_params).execute + PersonalAccessTokens::CreateService.new( + current_user: user, target_user: user, params: personal_access_token_params + ).execute end def personal_access_token_params diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 607e759928c..3af4437a63a 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,7 +1,10 @@ - if @search_objects.to_a.empty? - = render partial: "search/results/filters" - = render partial: "search/results/empty" - = render_if_exists 'shared/promotions/promote_advanced_search' + .gl-display-md-flex + - if %w(issues merge_requests).include?(@scope) + #js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ } + .gl-w-full + = render partial: "search/results/empty" + = render_if_exists 'shared/promotions/promote_advanced_search' - else .search-results-status .row-content-block.gl-display-flex @@ -24,19 +27,21 @@ .gl-display-md-flex.gl-flex-direction-column = render partial: 'search/sort_dropdown' = render_if_exists 'shared/promotions/promote_advanced_search' - = render partial: "search/results/filters" - .results.gl-mt-3 - - if @scope == 'commits' - %ul.content-list.commit-list - = render partial: "search/results/commit", collection: @search_objects - - else - .search-results - - if @scope == 'projects' - .term - = render 'shared/projects/list', projects: @search_objects, pipeline_status: false - - else - = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects + .results.gl-display-md-flex.gl-mt-3 + - if %w(issues merge_requests).include?(@scope) + #js-search-sidebar{ } + .gl-w-full + - if @scope == 'commits' + %ul.content-list.commit-list + = render partial: "search/results/commit", collection: @search_objects + - else + .search-results + - if @scope == 'projects' + .term + = render 'shared/projects/list', projects: @search_objects, pipeline_status: false + - else + = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects - - if @scope != 'projects' - = paginate_collection(@search_objects) + - if @scope != 'projects' + = paginate_collection(@search_objects) diff --git a/app/views/search/results/_filters.html.haml b/app/views/search/results/_filters.html.haml deleted file mode 100644 index 2356a6e1f2c..00000000000 --- a/app/views/search/results/_filters.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -.d-lg-flex.align-items-end - #js-search-filter-by-state{ 'v-cloak': true } - #js-search-filter-by-confidential{ 'v-cloak': true } - - - if %w(issues merge_requests).include?(@scope) - %hr.gl-mt-4.gl-mb-4 diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index f56a6cd9fa2..35844fdf297 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -7,11 +7,19 @@ class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker feature_category :authentication_and_authorization worker_resource_boundary :cpu + # rubocop: disable CodeReuse/ActiveRecord def perform - Member.expired.find_each do |member| + Member.expired.preload(:user).find_each do |member| Members::DestroyService.new.execute(member, skip_authorization: true) + + expired_user = member.user + + if expired_user.project_bot? + Users::DestroyService.new(nil).execute(expired_user, skip_authorization: true) + end rescue => ex logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}") end end + # rubocop: enable CodeReuse/ActiveRecord end diff --git a/changelogs/unreleased/218531-determine-image-relative-paths.yml b/changelogs/unreleased/218531-determine-image-relative-paths.yml new file mode 100644 index 00000000000..7265f2e6e16 --- /dev/null +++ b/changelogs/unreleased/218531-determine-image-relative-paths.yml @@ -0,0 +1,5 @@ +--- +title: Determine image relative paths +merge_request: 46208 +author: +type: added diff --git a/changelogs/unreleased/221035-ide-solarized-light.yml b/changelogs/unreleased/221035-ide-solarized-light.yml new file mode 100644 index 00000000000..5ae6f4120f2 --- /dev/null +++ b/changelogs/unreleased/221035-ide-solarized-light.yml @@ -0,0 +1,5 @@ +--- +title: Add Web IDE Solarized Light theme support +merge_request: 46999 +author: +type: added diff --git a/changelogs/unreleased/235385-ide-monokai-theme.yml b/changelogs/unreleased/235385-ide-monokai-theme.yml new file mode 100644 index 00000000000..a63c27bdd4c --- /dev/null +++ b/changelogs/unreleased/235385-ide-monokai-theme.yml @@ -0,0 +1,5 @@ +--- +title: Monokai theme for the Web IDE +merge_request: 46901 +author: +type: added diff --git a/changelogs/unreleased/241691-left-side-facets.yml b/changelogs/unreleased/241691-left-side-facets.yml new file mode 100644 index 00000000000..bafbf36297b --- /dev/null +++ b/changelogs/unreleased/241691-left-side-facets.yml @@ -0,0 +1,5 @@ +--- +title: Global Search - Left Sidebar +merge_request: 46595 +author: +type: added diff --git a/changelogs/unreleased/250484-add-locked-and-confidential-badge-to-issue-sticky-header.yml b/changelogs/unreleased/250484-add-locked-and-confidential-badge-to-issue-sticky-header.yml new file mode 100644 index 00000000000..514a7b5b3c0 --- /dev/null +++ b/changelogs/unreleased/250484-add-locked-and-confidential-badge-to-issue-sticky-header.yml @@ -0,0 +1,5 @@ +--- +title: Add locked and confidential badge to issue sticky header +merge_request: 46996 +author: +type: added diff --git a/changelogs/unreleased/267191-project-access-tokens-delete-project-bot-after-it-s-removed-from-p.yml b/changelogs/unreleased/267191-project-access-tokens-delete-project-bot-after-it-s-removed-from-p.yml new file mode 100644 index 00000000000..b6cec22bc92 --- /dev/null +++ b/changelogs/unreleased/267191-project-access-tokens-delete-project-bot-after-it-s-removed-from-p.yml @@ -0,0 +1,5 @@ +--- +title: Project Access Tokens - Delete project bot after token expires +merge_request: 45828 +author: +type: fixed diff --git a/changelogs/unreleased/273739-pipeline-tooltips-cover-the-entire-element.yml b/changelogs/unreleased/273739-pipeline-tooltips-cover-the-entire-element.yml new file mode 100644 index 00000000000..4fb470d9010 --- /dev/null +++ b/changelogs/unreleased/273739-pipeline-tooltips-cover-the-entire-element.yml @@ -0,0 +1,5 @@ +--- +title: Better-behaved tooltips in pipeline dropdown +merge_request: 46866 +author: +type: fixed diff --git a/config/feature_flags/development/search_facets.yml b/config/feature_flags/development/search_facets.yml deleted file mode 100644 index b100c4a6490..00000000000 --- a/config/feature_flags/development/search_facets.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: search_facets -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46809 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46595 -group: group::global search -type: development -default_enabled: false diff --git a/config/feature_flags/licensed/minimal_access_role.yml b/config/feature_flags/licensed/minimal_access_role.yml deleted file mode 100644 index ca27b86d35f..00000000000 --- a/config/feature_flags/licensed/minimal_access_role.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: minimal_access_role -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40942 -rollout_issue_url: -group: group::access -type: licensed -default_enabled: true diff --git a/config/initializers/0_inject_feature_flags.rb b/config/initializers/0_inject_feature_flags.rb index 78fd67b97df..726336ccd0c 100644 --- a/config/initializers/0_inject_feature_flags.rb +++ b/config/initializers/0_inject_feature_flags.rb @@ -16,7 +16,6 @@ if Gitlab.ee? && Gitlab.dev_or_test_env? IGNORED_FEATURE_FLAGS = %i[ group_wikis swimlanes - minimal_access_role ].to_set # First, we validate a list of overrides to ensure that these overrides diff --git a/doc/README.md b/doc/README.md index 1589c73bb1d..df7ad597334 100644 --- a/doc/README.md +++ b/doc/README.md @@ -25,7 +25,7 @@ No matter how you use GitLab, we have documentation for you. | Essential documentation | Essential documentation | |:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------| -| [**User documentation**](user/index.md)<br/>Discover features and concepts for GitLab users. | [**Administrator documentation**](administration/index.md)<br/>Everything GitLab self-managed administrators need to know. | +| [**User documentation**](user/index.md)<br>Discover features and concepts for GitLab users. | [**Administrator documentation**](administration/index.md)<br/>Everything GitLab self-managed administrators need to know. | | [**Contributing to GitLab**](#contributing-to-gitlab)<br/>At GitLab, everyone can contribute! | [**New to Git and GitLab?**](#new-to-git-and-gitlab)<br/>We have the resources to get you started. | | [**Build an integration with GitLab**](#build-an-integration-with-gitlab)<br/>Consult our automation and integration documentation. | [**Coming to GitLab from another platform?**](#coming-to-gitlab-from-another-platform)<br/>Consult our handy guides. | | [**Install GitLab**](https://about.gitlab.com/install/)<br/>Installation options for different platforms. | [**Customers**](subscriptions/index.md)<br/>Information for new and existing customers. | diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md index 825bee93714..4b8aaa8157a 100644 --- a/doc/administration/audit_events.md +++ b/doc/administration/audit_events.md @@ -127,6 +127,8 @@ recorded: - User was blocked via Admin Area ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/251) in GitLab 12.8) - User was blocked via API ([introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25872) in GitLab 12.9) - Failed second-factor authentication attempt ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/16826) in GitLab 13.5) +- A user's personal access token was successfully created or revoked ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/276921) in GitLab 13.6) +- A failed attempt to create or revoke a user's personal access token ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/276921) in GitLab 13.6) It's possible to filter particular actions by choosing an audit data type from the filter dropdown box. You can further filter by specific group, project, or user diff --git a/lib/api/internal/base.rb b/lib/api/internal/base.rb index e09d074d749..410bc1b0974 100644 --- a/lib/api/internal/base.rb +++ b/lib/api/internal/base.rb @@ -245,7 +245,7 @@ module API end result = ::PersonalAccessTokens::CreateService.new( - user, name: params[:name], scopes: params[:scopes], expires_at: expires_at + current_user: user, target_user: user, params: { name: params[:name], scopes: params[:scopes], expires_at: expires_at } ).execute unless result.status == :success diff --git a/lib/api/personal_access_tokens.rb b/lib/api/personal_access_tokens.rb index 599b3ee034e..2c60938b75a 100644 --- a/lib/api/personal_access_tokens.rb +++ b/lib/api/personal_access_tokens.rb @@ -51,7 +51,7 @@ module API delete ':id' do service = ::PersonalAccessTokens::RevokeService.new( current_user, - { token: find_token(params[:id]) } + token: find_token(params[:id]) ).execute service.success? ? no_content! : bad_request!(nil) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2bb773aa13e..8af1f073b0a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2566,6 +2566,9 @@ msgstr "" msgid "AlertSettings|HTTP endpoint" msgstr "" +msgid "AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations." +msgstr "" + msgid "AlertSettings|Integration" msgstr "" @@ -2668,6 +2671,9 @@ msgstr "" msgid "AlertsIntegrations|Prometheus" msgstr "" +msgid "AlertsIntegrations|The current integration could not be updated. Please try again." +msgstr "" + msgid "AlertsIntegrations|The integration could not be added. Please try again." msgstr "" @@ -2680,6 +2686,9 @@ msgstr "" msgid "AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list." msgstr "" +msgid "AlertsIntegrations|The integration token could not be reset. Please try again." +msgstr "" + msgid "AlertsIntegrations|You have opted to delete the %{integrationName} integration. Do you want to proceed? It means you will no longer receive alerts from this endpoint in your alert list, and this action cannot be undone." msgstr "" @@ -3582,9 +3591,6 @@ msgstr "" msgid "Are you sure you want to delete this device? This action cannot be undone." msgstr "" -msgid "Are you sure you want to delete this list?" -msgstr "" - msgid "Are you sure you want to delete this pipeline schedule?" msgstr "" @@ -3635,6 +3641,9 @@ msgstr "" msgid "Are you sure you want to remove this identity?" msgstr "" +msgid "Are you sure you want to remove this list?" +msgstr "" + msgid "Are you sure you want to reset registration token?" msgstr "" @@ -4403,6 +4412,9 @@ msgstr "" msgid "Boards|An error occurred while moving the issue. Please try again." msgstr "" +msgid "Boards|An error occurred while removing the list. Please try again." +msgstr "" + msgid "Boards|An error occurred while updating the list. Please try again." msgstr "" @@ -22941,6 +22953,9 @@ msgstr "" msgid "Reset authorization key?" msgstr "" +msgid "Reset filters" +msgstr "" + msgid "Reset health check access token" msgstr "" diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh index 9d6a4dbccee..1b671df84e6 100755 --- a/scripts/lint-doc.sh +++ b/scripts/lint-doc.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -o pipefail cd "$(dirname "$0")/.." || exit 1 echo "=> Linting documents at path $(pwd) as $(whoami)..." @@ -71,13 +72,14 @@ else function run_locally_or_in_docker() { local cmd=$1 local args=$2 + local pipe_cmd=$3 if hash ${cmd} 2>/dev/null then - $cmd $args + $cmd $args | $pipe_cmd elif hash docker 2>/dev/null then - docker run -t -v ${PWD}:/gitlab -w /gitlab --rm registry.gitlab.com/gitlab-org/gitlab-docs/lint:latest ${cmd} ${args} + docker run -t -v ${PWD}:/gitlab -w /gitlab --rm registry.gitlab.com/gitlab-org/gitlab-docs/lint:latest ${cmd} ${args} | $pipe_cmd else echo echo " ✖ ERROR: '${cmd}' not found. Install '${cmd}' or Docker to proceed." >&2 @@ -99,7 +101,7 @@ echo run_locally_or_in_docker 'markdownlint' "--config .markdownlint.json ${MD_DOC_PATH}" echo '=> Linting prose...' -run_locally_or_in_docker 'vale' "--minAlertLevel error ${MD_DOC_PATH}" +run_locally_or_in_docker 'vale' "--minAlertLevel error --output=JSON ${MD_DOC_PATH}" "ruby scripts/vale.rb" if [ $ERRORCODE -ne 0 ] then diff --git a/scripts/vale.rb b/scripts/vale.rb new file mode 100755 index 00000000000..22c6a474019 --- /dev/null +++ b/scripts/vale.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +# +# Get the JSON output from Vale and format it in a nicer way +# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46725 +# +# Usage: +# vale --output=JSON filename.md | ruby vale.rb +# + +require 'json' + +input = ARGF.read +data = JSON.parse(input) + +data.each_pair do |source, alerts| + alerts.each do |alert| + puts "#{source}:" + puts " Line #{alert['Line']}, position #{alert['Span'][0]} (rule #{alert['Check']})" + puts " #{alert['Severity']}: #{alert['Message']}" + puts " More information: #{alert['Link']}" + puts + end +end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 4438831fb76..de5a594aca6 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe 'Profile > Personal Access Tokens', :js do let(:user) { create(:user) } + let(:pat_create_service) { double('PersonalAccessTokens::CreateService', execute: ServiceResponse.error(message: 'error', payload: { personal_access_token: PersonalAccessToken.new })) } def active_personal_access_tokens find(".table.active-tokens") @@ -18,7 +19,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do end def disallow_personal_access_token_saves! - allow_any_instance_of(PersonalAccessToken).to receive(:save).and_return(false) + allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service) errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") } allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors) @@ -100,7 +101,10 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do context "when revocation fails" do it "displays an error message" do visit profile_personal_access_tokens_path - allow_any_instance_of(PersonalAccessTokens::RevokeService).to receive(:revocation_permitted?).and_return(false) + + allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance| + allow(instance).to receive(:revocation_permitted?).and_return(false) + end accept_confirm { click_on "Revoke" } expect(active_personal_access_tokens).to have_text(personal_access_token.name) diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap index 68b5eb12b99..629cbd68bd6 100644 --- a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap +++ b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap @@ -9,7 +9,9 @@ exports[`AlertsSettingsFormNew with default values renders the initial template <option value=\\"HTTP\\">HTTP Endpoint</option> <option value=\\"PROMETHEUS\\">External Prometheus</option> <option value=\\"OPSGENIE\\">Opsgenie</option> - </select> <span class=\\"gl-text-gray-500\\">Learn more about our upcoming <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://gitlab.com/groups/gitlab-org/-/epics/4390\\" class=\\"gl-link gl-display-inline-block\\">integrations</a></span> + </select> + <div class=\\"gl-my-4\\"><span class=\\"gl-text-gray-500\\">Learn more about our upcoming <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://gitlab.com/groups/gitlab-org/-/epics/4390\\" class=\\"gl-link gl-display-inline-block\\">integrations</a></span></div> + <!----> <!----> <!----> <!----> diff --git a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js index 5d1feffe84a..c7a9db82bea 100644 --- a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js +++ b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js @@ -1,4 +1,4 @@ -import { GlTable, GlIcon } from '@gitlab/ui'; +import { GlTable, GlIcon, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Tracking from '~/tracking'; import AlertIntegrationsList, { @@ -8,11 +8,13 @@ import { trackAlertIntegrationsViewsOptions } from '~/alerts_settings/constants' const mockIntegrations = [ { + id: '1', active: true, name: 'Integration 1', type: 'HTTP endpoint', }, { + id: '2', active: false, name: 'Integration 2', type: 'HTTP endpoint', @@ -30,6 +32,7 @@ describe('AlertIntegrationsList', () => { }, stubs: { GlIcon: true, + GlButton: true, }, }); } @@ -46,6 +49,7 @@ describe('AlertIntegrationsList', () => { }); const findTableComponent = () => wrapper.find(GlTable); + const findTableComponentRows = () => wrapper.find(GlTable).findAll('table tbody tr'); const finsStatusCell = () => wrapper.findAll('[data-testid="integration-activated-status"]'); it('renders a table', () => { @@ -57,6 +61,19 @@ describe('AlertIntegrationsList', () => { expect(findTableComponent().text()).toContain(i18n.emptyState); }); + it('renders an an edit and delete button for each integration', () => { + expect(findTableComponent().findAll(GlButton).length).toBe(4); + }); + + it('renders an highlighted row when a current integration is selected to edit', () => { + mountComponent({ currentIntegration: { id: '1' } }); + expect( + findTableComponentRows() + .at(0) + .classes(), + ).toContain('gl-bg-blue-50'); + }); + describe('integration status', () => { it('enabled', () => { const cell = finsStatusCell().at(0); diff --git a/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js index 59238e40686..8ab760dd962 100644 --- a/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js +++ b/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js @@ -9,7 +9,7 @@ describe('AlertsSettingsFormNew', () => { const createComponent = ({ data = {}, - props = { loading: false }, + props = {}, multipleHttpIntegrationsCustomMapping = false, } = {}) => { wrapper = mount(AlertsSettingsForm, { @@ -17,6 +17,8 @@ describe('AlertsSettingsFormNew', () => { return { ...data }; }, propsData: { + loading: false, + canAddIntegration: true, ...props, }, provide: { @@ -33,6 +35,8 @@ describe('AlertsSettingsFormNew', () => { const findFormToggle = () => wrapper.find(GlToggle); const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); const findSubmitButton = () => wrapper.find(`[type = "submit"]`); + const findMultiSupportText = () => + wrapper.find(`[data-testid="multi-integrations-not-supported"]`); afterEach(() => { if (wrapper) { @@ -53,6 +57,7 @@ describe('AlertsSettingsFormNew', () => { it('render the initial form with only an integration type dropdown', () => { expect(findForm().exists()).toBe(true); expect(findSelect().exists()).toBe(true); + expect(findMultiSupportText().exists()).toBe(false); expect(findFormSteps().attributes('visible')).toBeUndefined(); }); @@ -68,6 +73,12 @@ describe('AlertsSettingsFormNew', () => { .isVisible(), ).toBe(true); }); + + it('disabled the dropdown and shows help text when multi integrations are not supported', async () => { + createComponent({ props: { canAddIntegration: false } }); + expect(findSelect().attributes('disabled')).toBe('disabled'); + expect(findMultiSupportText().exists()).toBe(true); + }); }); describe('submitting integration form', () => { diff --git a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js index e2e7034940d..dc3a490154b 100644 --- a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js @@ -1,6 +1,7 @@ import VueApollo from 'vue-apollo'; import { mount, createLocalVue } from '@vue/test-utils'; import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { GlLoadingIcon } from '@gitlab/ui'; import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue'; import AlertsSettingsFormOld from '~/alerts_settings/components/alerts_settings_form_old.vue'; @@ -15,6 +16,11 @@ import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/ import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql'; import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql'; import { typeSet } from '~/alerts_settings/constants'; +import { + ADD_INTEGRATION_ERROR, + RESET_INTEGRATION_TOKEN_ERROR, + UPDATE_INTEGRATION_ERROR, +} from '~/alerts_settings/utils/error_messages'; import createFlash from '~/flash'; import { defaultAlertSettingsConfig } from './util'; import mockIntegrations from './mocks/integrations.json'; @@ -143,16 +149,6 @@ describe('AlertsSettingsWrapper', () => { expect(findIntegrations()).toHaveLength(mockIntegrations.length); }); - it('shows an error message when a user cannot create a new integration', () => { - createComponent({ - data: { integrations: { list: mockIntegrations } }, - provide: { glFeatures: { httpIntegrationsList: true } }, - loading: false, - }); - expect(findLoader().exists()).toBe(false); - expect(findIntegrations()).toHaveLength(mockIntegrations.length); - }); - it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, @@ -287,38 +283,37 @@ describe('AlertsSettingsWrapper', () => { }); }); - it('shows error alert when integration creation fails ', async () => { + it('shows an error alert when integration creation fails ', async () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR); wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {}); - setImmediate(() => { - expect(createFlash).toHaveBeenCalledWith({ message: errorMsg }); - }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: ADD_INTEGRATION_ERROR }); }); - it('shows error alert when integration token reset fails ', () => { + it('shows an error alert when integration token reset fails ', async () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR); wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {}); - setImmediate(() => { - expect(createFlash).toHaveBeenCalledWith({ message: errorMsg }); - }); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); }); - it('shows error alert when integration update fails ', () => { + it('shows an error alert when integration update fails ', async () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, provide: { glFeatures: { httpIntegrationsList: true } }, @@ -329,9 +324,8 @@ describe('AlertsSettingsWrapper', () => { wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {}); - setImmediate(() => { - expect(createFlash).toHaveBeenCalledWith({ message: errorMsg }); - }); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); }); }); diff --git a/spec/frontend/alerts_settings/util.js b/spec/frontend/alerts_settings/util.js index beb6a724f20..f9f9b69791e 100644 --- a/spec/frontend/alerts_settings/util.js +++ b/spec/frontend/alerts_settings/util.js @@ -25,4 +25,6 @@ export const defaultAlertSettingsConfig = { active: ACTIVE, opsgenieMvcTargetUrl: GENERIC_URL, }, + projectPath: '', + multiIntegrations: true, }; diff --git a/spec/frontend/boards/components/board_assignee_dropdown_spec.js b/spec/frontend/boards/components/board_assignee_dropdown_spec.js index 4d3129da11a..f81a96c645d 100644 --- a/spec/frontend/boards/components/board_assignee_dropdown_spec.js +++ b/spec/frontend/boards/components/board_assignee_dropdown_spec.js @@ -18,7 +18,7 @@ describe('BoardCardAssigneeDropdown', () => { wrapper = mount(BoardAssigneeDropdown, { data() { return { - selected: store.getters.getActiveIssue.assignees, + selected: store.getters.activeIssue.assignees, participants, }; }, diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index d83b39a5594..7ab7836ddcd 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -2,6 +2,7 @@ /* global List */ import Vue from 'vue'; +import { keyBy } from 'lodash'; import '~/boards/models/list'; import '~/boards/models/issue'; import boardsStore from '~/boards/stores/boards_store'; @@ -310,6 +311,8 @@ export const mockLists = [ }, ]; +export const mockListsById = keyBy(mockLists, 'id'); + export const mockListsWithModel = mockLists.map(listMock => Vue.observable(new List({ ...listMock, doNotFetchIssues: true })), ); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 3b204c3cf70..44dd44edb12 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -2,6 +2,7 @@ import testAction from 'helpers/vuex_action_helper'; import { mockListsWithModel, mockLists, + mockListsById, mockIssue, mockIssueWithModel, mockIssue2WithModel, @@ -13,6 +14,7 @@ import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import { inactiveId } from '~/boards/constants'; import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql'; +import destroyBoardListMutation from '~/boards/queries/board_list_destroy.mutation.graphql'; import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util'; @@ -318,8 +320,82 @@ describe('updateList', () => { }); }); -describe('deleteList', () => { - expectNotImplemented(actions.deleteList); +describe('removeList', () => { + let state; + const list = mockLists[0]; + const listId = list.id; + const mutationVariables = { + mutation: destroyBoardListMutation, + variables: { + listId, + }, + }; + + beforeEach(() => { + state = { + boardLists: mockListsById, + }; + }); + + afterEach(() => { + state = null; + }); + + it('optimistically deletes the list', () => { + const commit = jest.fn(); + + actions.removeList({ commit, state }, listId); + + expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]); + }); + + it('keeps the updated list if remove succeeds', async () => { + const commit = jest.fn(); + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + destroyBoardList: { + errors: [], + }, + }, + }); + + await actions.removeList({ commit, state }, listId); + + expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); + expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]); + }); + + it('restores the list if update fails', async () => { + const commit = jest.fn(); + jest.spyOn(gqlClient, 'mutate').mockResolvedValue(Promise.reject()); + + await actions.removeList({ commit, state }, listId); + + expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); + expect(commit.mock.calls).toEqual([ + [types.REMOVE_LIST, listId], + [types.REMOVE_LIST_FAILURE, mockListsById], + ]); + }); + + it('restores the list if update response has errors', async () => { + const commit = jest.fn(); + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + destroyBoardList: { + errors: ['update failed, ID invalid'], + }, + }, + }); + + await actions.removeList({ commit, state }, listId); + + expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); + expect(commit.mock.calls).toEqual([ + [types.REMOVE_LIST, listId], + [types.REMOVE_LIST_FAILURE, mockListsById], + ]); + }); }); describe('fetchIssuesForList', () => { @@ -640,7 +716,7 @@ describe('addListIssueFailure', () => { describe('setActiveIssueLabels', () => { const state = { issues: { [mockIssue.id]: mockIssue } }; - const getters = { getActiveIssue: mockIssue }; + const getters = { activeIssue: mockIssue }; const testLabelIds = labels.map(label => label.id); const input = { addLabelIds: testLabelIds, @@ -654,7 +730,7 @@ describe('setActiveIssueLabels', () => { .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); const payload = { - issueId: getters.getActiveIssue.id, + issueId: getters.activeIssue.id, prop: 'labels', value: labels, }; @@ -685,7 +761,7 @@ describe('setActiveIssueLabels', () => { describe('setActiveIssueDueDate', () => { const state = { issues: { [mockIssue.id]: mockIssue } }; - const getters = { getActiveIssue: mockIssue }; + const getters = { activeIssue: mockIssue }; const testDueDate = '2020-02-20'; const input = { dueDate: testDueDate, @@ -705,7 +781,7 @@ describe('setActiveIssueDueDate', () => { }); const payload = { - issueId: getters.getActiveIssue.id, + issueId: getters.activeIssue.id, prop: 'dueDate', value: testDueDate, }; diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index b987080abab..66c26d087bb 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -10,13 +10,13 @@ import { } from '../mock_data'; describe('Boards - Getters', () => { - describe('getLabelToggleState', () => { + describe('labelToggleState', () => { it('should return "on" when isShowingLabels is true', () => { const state = { isShowingLabels: true, }; - expect(getters.getLabelToggleState(state)).toBe('on'); + expect(getters.labelToggleState(state)).toBe('on'); }); it('should return "off" when isShowingLabels is false', () => { @@ -24,7 +24,7 @@ describe('Boards - Getters', () => { isShowingLabels: false, }; - expect(getters.getLabelToggleState(state)).toBe('off'); + expect(getters.labelToggleState(state)).toBe('off'); }); }); @@ -112,7 +112,7 @@ describe('Boards - Getters', () => { }); }); - describe('getActiveIssue', () => { + describe('activeIssue', () => { it.each` id | expected ${'1'} | ${'issue'} @@ -120,11 +120,11 @@ describe('Boards - Getters', () => { `('returns $expected when $id is passed to state', ({ id, expected }) => { const state = { issues: { '1': 'issue' }, activeId: id }; - expect(getters.getActiveIssue(state)).toEqual(expected); + expect(getters.activeIssue(state)).toEqual(expected); }); }); - describe('getIssues', () => { + describe('getIssuesByList', () => { const boardsState = { issuesByListId: mockIssuesByListId, issues, @@ -132,7 +132,7 @@ describe('Boards - Getters', () => { it('returns issues for a given listId', () => { const getIssueById = issueId => [mockIssue, mockIssue2].find(({ id }) => id === issueId); - expect(getters.getIssues(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual( + expect(getters.getIssuesByList(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual( mockIssues, ); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 86056b922be..0036d1eafe1 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -184,16 +184,43 @@ describe('Board Store Mutations', () => { }); }); - describe('REQUEST_REMOVE_LIST', () => { - expectNotImplemented(mutations.REQUEST_REMOVE_LIST); - }); + describe('REMOVE_LIST', () => { + it('removes list from boardLists', () => { + const [list, secondList] = mockListsWithModel; + const expected = { + [secondList.id]: secondList, + }; + state = { + ...state, + boardLists: { ...initialBoardListsState }, + }; - describe('RECEIVE_REMOVE_LIST_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_SUCCESS); + mutations[types.REMOVE_LIST](state, list.id); + + expect(state.boardLists).toEqual(expected); + }); }); - describe('RECEIVE_REMOVE_LIST_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR); + describe('REMOVE_LIST_FAILURE', () => { + it('restores lists from backup', () => { + const backupLists = { ...initialBoardListsState }; + + mutations[types.REMOVE_LIST_FAILURE](state, backupLists); + + expect(state.boardLists).toEqual(backupLists); + }); + + it('sets error state', () => { + const backupLists = { ...initialBoardListsState }; + state = { + ...state, + error: undefined, + }; + + mutations[types.REMOVE_LIST_FAILURE](state, backupLists); + + expect(state.error).toEqual('An error occurred while removing the list. Please try again.'); + }); }); describe('RESET_ISSUES', () => { diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js index 52386bf6ede..6a630195126 100644 --- a/spec/frontend/graphql_shared/utils_spec.js +++ b/spec/frontend/graphql_shared/utils_spec.js @@ -11,6 +11,10 @@ describe('getIdFromGraphQLId', () => { output: null, }, { + input: 2, + output: 2, + }, + { input: 'gid://', output: null, }, diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js index 86e4e8d8f89..72e9463945b 100644 --- a/spec/frontend/ide/components/ide_side_bar_spec.js +++ b/spec/frontend/ide/components/ide_side_bar_spec.js @@ -1,10 +1,12 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { GlSkeletonLoading } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import { createStore } from '~/ide/stores'; import IdeSidebar from '~/ide/components/ide_side_bar.vue'; import IdeTree from '~/ide/components/ide_tree.vue'; import RepoCommitSection from '~/ide/components/repo_commit_section.vue'; +import IdeReview from '~/ide/components/ide_review.vue'; import { leftSidebarViews } from '~/ide/constants'; import { projectData } from '../mock_data'; @@ -15,11 +17,12 @@ describe('IdeSidebar', () => { let wrapper; let store; - function createComponent() { + function createComponent({ view = leftSidebarViews.edit.name } = {}) { store = createStore(); store.state.currentProjectId = 'abcproject'; store.state.projects.abcproject = projectData; + store.state.currentActivityView = view; return mount(IdeSidebar, { store, @@ -48,22 +51,46 @@ describe('IdeSidebar', () => { expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(3); }); - describe('activityBarComponent', () => { - it('renders tree component', () => { + describe('deferred rendering components', () => { + it('fetches components on demand', async () => { wrapper = createComponent(); expect(wrapper.find(IdeTree).exists()).toBe(true); - }); + expect(wrapper.find(IdeReview).exists()).toBe(false); + expect(wrapper.find(RepoCommitSection).exists()).toBe(false); - it('renders commit component', async () => { - wrapper = createComponent(); + store.state.currentActivityView = leftSidebarViews.review.name; + await waitForPromises(); + await wrapper.vm.$nextTick(); - store.state.currentActivityView = leftSidebarViews.commit.name; + expect(wrapper.find(IdeTree).exists()).toBe(false); + expect(wrapper.find(IdeReview).exists()).toBe(true); + expect(wrapper.find(RepoCommitSection).exists()).toBe(false); + store.state.currentActivityView = leftSidebarViews.commit.name; + await waitForPromises(); await wrapper.vm.$nextTick(); + expect(wrapper.find(IdeTree).exists()).toBe(false); + expect(wrapper.find(IdeReview).exists()).toBe(false); expect(wrapper.find(RepoCommitSection).exists()).toBe(true); }); + it.each` + view | tree | review | commit + ${leftSidebarViews.edit.name} | ${true} | ${false} | ${false} + ${leftSidebarViews.review.name} | ${false} | ${true} | ${false} + ${leftSidebarViews.commit.name} | ${false} | ${false} | ${true} + `('renders correct panels for $view', async ({ view, tree, review, commit } = {}) => { + wrapper = createComponent({ + view, + }); + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(IdeTree).exists()).toBe(tree); + expect(wrapper.find(IdeReview).exists()).toBe(review); + expect(wrapper.find(RepoCommitSection).exists()).toBe(commit); + }); }); it('keeps the current activity view components alive', async () => { @@ -72,7 +99,7 @@ describe('IdeSidebar', () => { const ideTreeComponent = wrapper.find(IdeTree).element; store.state.currentActivityView = leftSidebarViews.commit.name; - + await waitForPromises(); await wrapper.vm.$nextTick(); expect(wrapper.find(IdeTree).exists()).toBe(false); @@ -80,6 +107,7 @@ describe('IdeSidebar', () => { store.state.currentActivityView = leftSidebarViews.edit.name; + await waitForPromises(); await wrapper.vm.$nextTick(); // reference to the elements remains the same, meaning the components were kept alive diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index a7b07a9f0e2..e402b03f782 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { createStore } from '~/ide/stores'; import ide from '~/ide/components/ide.vue'; import { file } from '../helpers'; @@ -63,18 +64,17 @@ describe('ide component, non-empty repo', () => { vm.$destroy(); }); - it('shows error message when set', done => { + it('shows error message when set', async () => { expect(vm.$el.querySelector('.gl-alert')).toBe(null); vm.$store.state.errorMessage = { text: 'error', }; - vm.$nextTick(() => { - expect(vm.$el.querySelector('.gl-alert')).not.toBe(null); + await waitForPromises(); + await vm.$nextTick(); - done(); - }); + expect(vm.$el.querySelector('.gl-alert')).not.toBe(null); }); describe('onBeforeUnload', () => { diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index f4095d4de96..dde4e8458d5 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -17,6 +17,7 @@ import { import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; import DescriptionComponent from '~/issue_show/components/description.vue'; import PinnedLinks from '~/issue_show/components/pinned_links.vue'; +import { IssuableStatus, IssuableStatusText } from '~/issue_show/constants'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); @@ -36,6 +37,10 @@ describe('Issuable output', () => { const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); + const findLockedBadge = () => wrapper.find('[data-testid="locked"]'); + + const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]'); + const mountComponent = (props = {}, options = {}) => { wrapper = mount(IssuableApp, { propsData: { ...appProps, ...props }, @@ -532,7 +537,7 @@ describe('Issuable output', () => { describe('sticky header', () => { describe('when title is in view', () => { it('is not shown', () => { - expect(wrapper.find('.issue-sticky-header').exists()).toBe(false); + expect(findStickyHeader().exists()).toBe(false); }); }); @@ -542,24 +547,45 @@ describe('Issuable output', () => { wrapper.find(GlIntersectionObserver).vm.$emit('disappear'); }); - it('is shown with title', () => { + it('shows with title', () => { expect(findStickyHeader().text()).toContain('Sticky header title'); }); - it('is shown with Open when status is opened', () => { - wrapper.setProps({ issuableStatus: 'opened' }); + it.each` + title | state + ${'shows with Open when status is opened'} | ${IssuableStatus.Open} + ${'shows with Closed when status is closed'} | ${IssuableStatus.Closed} + ${'shows with Open when status is reopened'} | ${IssuableStatus.Reopened} + `('$title', async ({ state }) => { + wrapper.setProps({ issuableStatus: state }); - return wrapper.vm.$nextTick(() => { - expect(findStickyHeader().text()).toContain('Open'); - }); + await wrapper.vm.$nextTick(); + + expect(findStickyHeader().text()).toContain(IssuableStatusText[state]); }); - it('is shown with Closed when status is closed', () => { - wrapper.setProps({ issuableStatus: 'closed' }); + it.each` + title | isConfidential + ${'does not show confidential badge when issue is not confidential'} | ${true} + ${'shows confidential badge when issue is confidential'} | ${false} + `('$title', async ({ isConfidential }) => { + wrapper.setProps({ isConfidential }); - return wrapper.vm.$nextTick(() => { - expect(findStickyHeader().text()).toContain('Closed'); - }); + await wrapper.vm.$nextTick(); + + expect(findConfidentialBadge().exists()).toBe(isConfidential); + }); + + it.each` + title | isLocked + ${'does not show locked badge when issue is not locked'} | ${true} + ${'shows locked badge when issue is locked'} | ${false} + `('$title', async ({ isLocked }) => { + wrapper.setProps({ isLocked }); + + await wrapper.vm.$nextTick(); + + expect(findLockedBadge().exists()).toBe(isLocked); }); }); }); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index 3e1e43d0c6a..b26eb00bfdc 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -13,11 +13,11 @@ const createDiscussionMock = (props = {}) => const createNoteMock = (props = {}) => Object.assign(JSON.parse(JSON.stringify(discussionMock.notes[0])), props); const createResolvableNote = () => - createNoteMock({ resolvable: true, current_user: { can_resolve: true } }); + createNoteMock({ resolvable: true, current_user: { can_resolve_discussion: true } }); const createUnresolvableNote = () => - createNoteMock({ resolvable: false, current_user: { can_resolve: false } }); + createNoteMock({ resolvable: false, current_user: { can_resolve_discussion: false } }); const createUnallowedNote = () => - createNoteMock({ resolvable: true, current_user: { can_resolve: false } }); + createNoteMock({ resolvable: true, current_user: { can_resolve_discussion: false } }); describe('DiscussionActions', () => { let wrapper; diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index a5b5204509e..cc434d6c952 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -272,6 +272,7 @@ describe('issue_note_form component', () => { wrapper = createComponentWrapper(); wrapper.setProps({ ...props, + isDraft: true, noteId: '', discussion: { ...discussionMock, for_commit: false }, }); @@ -292,6 +293,27 @@ describe('issue_note_form component', () => { expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(true); }); + it('hides resolve checkbox', async () => { + wrapper.setProps({ + isDraft: false, + discussion: { + ...discussionMock, + notes: [ + ...discussionMock.notes.map(n => ({ + ...n, + resolvable: true, + current_user: { ...n.current_user, can_resolve_discussion: false }, + })), + ], + for_commit: false, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(false); + }); + it('hides actions for commits', () => { wrapper.setProps({ discussion: { for_commit: true } }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index 7661d51aadf..638a4edecd6 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -202,6 +202,7 @@ export const discussionMock = { can_edit: true, can_award_emoji: true, can_resolve: true, + can_resolve_discussion: true, }, discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', emoji_awardable: true, @@ -249,6 +250,7 @@ export const discussionMock = { can_edit: true, can_award_emoji: true, can_resolve: true, + can_resolve_discussion: true, }, discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', emoji_awardable: true, @@ -296,6 +298,7 @@ export const discussionMock = { can_edit: true, can_award_emoji: true, can_resolve: true, + can_resolve_discussion: true, }, discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', emoji_awardable: true, diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 5e04f9a6433..a272803f9b6 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -372,7 +372,6 @@ describe('Pipelines', () => { }); it('should render table', () => { - expect(wrapper.find('.table-holder').exists()).toBe(true); expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength( pipelines.pipelines.length + 1, ); diff --git a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js b/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js deleted file mode 100644 index f795a23404e..00000000000 --- a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js +++ /dev/null @@ -1,198 +0,0 @@ -import Vuex from 'vuex'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { MOCK_QUERY } from 'jest/search/mock_data'; -import * as urlUtils from '~/lib/utils/url_utility'; -import initStore from '~/search/store'; -import DropdownFilter from '~/search/dropdown_filter/components/dropdown_filter.vue'; -import stateFilterData from '~/search/dropdown_filter/constants/state_filter_data'; -import confidentialFilterData from '~/search/dropdown_filter/constants/confidential_filter_data'; - -jest.mock('~/lib/utils/url_utility', () => ({ - visitUrl: jest.fn(), - setUrlParams: jest.fn(), -})); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('DropdownFilter', () => { - let wrapper; - let store; - - const createStore = options => { - store = initStore({ query: MOCK_QUERY, ...options }); - }; - - const createComponent = (props = { filterData: stateFilterData }) => { - wrapper = shallowMount(DropdownFilter, { - localVue, - store, - propsData: { - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - store = null; - }); - - const findGlDropdown = () => wrapper.find(GlDropdown); - const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); - const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text()); - const firstDropDownItem = () => findGlDropdownItems().at(0); - - describe('StatusFilter', () => { - describe('template', () => { - describe.each` - scope | showDropdown - ${'issues'} | ${true} - ${'merge_requests'} | ${true} - ${'projects'} | ${false} - ${'milestones'} | ${false} - ${'users'} | ${false} - ${'notes'} | ${false} - ${'wiki_blobs'} | ${false} - ${'blobs'} | ${false} - `(`dropdown`, ({ scope, showDropdown }) => { - beforeEach(() => { - createStore({ query: { ...MOCK_QUERY, scope } }); - createComponent(); - }); - - it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => { - expect(findGlDropdown().exists()).toBe(showDropdown); - }); - }); - - describe.each` - initialFilter | label - ${stateFilterData.filters.ANY.value} | ${`Any ${stateFilterData.header}`} - ${stateFilterData.filters.OPEN.value} | ${stateFilterData.filters.OPEN.label} - ${stateFilterData.filters.CLOSED.value} | ${stateFilterData.filters.CLOSED.label} - `(`filter text`, ({ initialFilter, label }) => { - describe(`when initialFilter is ${initialFilter}`, () => { - beforeEach(() => { - createStore({ query: { ...MOCK_QUERY, [stateFilterData.filterParam]: initialFilter } }); - createComponent(); - }); - - it(`sets dropdown label to ${label}`, () => { - expect(findGlDropdown().attributes('text')).toBe(label); - }); - }); - }); - }); - - describe('Filter options', () => { - beforeEach(() => { - createStore(); - createComponent(); - }); - - it('renders a dropdown item for each filterOption', () => { - expect(findDropdownItemsText()).toStrictEqual( - stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(v => { - return v.label; - }), - ); - }); - - it('clicking a dropdown item calls setUrlParams', () => { - const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value; - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ - page: null, - [stateFilterData.filterParam]: filter, - }); - }); - - it('clicking a dropdown item calls visitUrl', () => { - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.visitUrl).toHaveBeenCalled(); - }); - }); - }); - - describe('ConfidentialFilter', () => { - describe('template', () => { - describe.each` - scope | showDropdown - ${'issues'} | ${true} - ${'merge_requests'} | ${false} - ${'projects'} | ${false} - ${'milestones'} | ${false} - ${'users'} | ${false} - ${'notes'} | ${false} - ${'wiki_blobs'} | ${false} - ${'blobs'} | ${false} - `(`dropdown`, ({ scope, showDropdown }) => { - beforeEach(() => { - createStore({ query: { ...MOCK_QUERY, scope } }); - createComponent({ filterData: confidentialFilterData }); - }); - - it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => { - expect(findGlDropdown().exists()).toBe(showDropdown); - }); - }); - - describe.each` - initialFilter | label - ${confidentialFilterData.filters.ANY.value} | ${`Any ${confidentialFilterData.header}`} - ${confidentialFilterData.filters.CONFIDENTIAL.value} | ${confidentialFilterData.filters.CONFIDENTIAL.label} - ${confidentialFilterData.filters.NOT_CONFIDENTIAL.value} | ${confidentialFilterData.filters.NOT_CONFIDENTIAL.label} - `(`filter text`, ({ initialFilter, label }) => { - describe(`when initialFilter is ${initialFilter}`, () => { - beforeEach(() => { - createStore({ - query: { ...MOCK_QUERY, [confidentialFilterData.filterParam]: initialFilter }, - }); - createComponent({ filterData: confidentialFilterData }); - }); - - it(`sets dropdown label to ${label}`, () => { - expect(findGlDropdown().attributes('text')).toBe(label); - }); - }); - }); - }); - }); - - describe('Filter options', () => { - beforeEach(() => { - createStore(); - createComponent({ filterData: confidentialFilterData }); - }); - - it('renders a dropdown item for each filterOption', () => { - expect(findDropdownItemsText()).toStrictEqual( - confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(v => { - return v.label; - }), - ); - }); - - it('clicking a dropdown item calls setUrlParams', () => { - const filter = - confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value; - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ - page: null, - [confidentialFilterData.filterParam]: filter, - }); - }); - - it('clicking a dropdown item calls visitUrl', () => { - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.visitUrl).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js new file mode 100644 index 00000000000..c68be10f664 --- /dev/null +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -0,0 +1,99 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlButton, GlLink } from '@gitlab/ui'; +import { MOCK_QUERY } from 'jest/search/mock_data'; +import GlobalSearchSidebar from '~/search/sidebar/components/app.vue'; +import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue'; +import StatusFilter from '~/search/sidebar/components/status_filter.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('GlobalSearchSidebar', () => { + let wrapper; + + const actionSpies = { + applyQuery: jest.fn(), + resetQuery: jest.fn(), + }; + + const createComponent = initialState => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = shallowMount(GlobalSearchSidebar, { + localVue, + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findSidebarForm = () => wrapper.find('form'); + const findStatusFilter = () => wrapper.find(StatusFilter); + const findConfidentialityFilter = () => wrapper.find(ConfidentialityFilter); + const findApplyButton = () => wrapper.find(GlButton); + const findResetLinkButton = () => wrapper.find(GlLink); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders StatusFilter always', () => { + expect(findStatusFilter().exists()).toBe(true); + }); + + it('renders ConfidentialityFilter always', () => { + expect(findConfidentialityFilter().exists()).toBe(true); + }); + + it('renders ApplyButton always', () => { + expect(findApplyButton().exists()).toBe(true); + }); + + describe('ResetLinkButton', () => { + describe('with no filter selected', () => { + beforeEach(() => { + createComponent({ query: {} }); + }); + + it('does not render', () => { + expect(findResetLinkButton().exists()).toBe(false); + }); + }); + + describe('with filter selected', () => { + it('does render when a filter selected', () => { + expect(findResetLinkButton().exists()).toBe(true); + }); + }); + }); + }); + + describe('actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('clicking ApplyButton calls applyQuery', () => { + findSidebarForm().trigger('submit'); + + expect(actionSpies.applyQuery).toHaveBeenCalled(); + }); + + it('clicking ResetLinkButton calls resetQuery', () => { + findResetLinkButton().vm.$emit('click'); + + expect(actionSpies.resetQuery).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 0bab4ce17a6..35d97c7dcb1 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/search/store/actions'; import * as types from '~/search/store/mutation_types'; +import * as urlUtils from '~/lib/utils/url_utility'; import state from '~/search/store/state'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; @@ -42,6 +43,47 @@ describe('Global Search Store Actions', () => { }); }); }); + + describe('setQuery', () => { + const payload = { key: 'key1', value: 'value1' }; + + it('calls the SET_QUERY mutation', done => { + testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done); + }); + }); + + describe('applyQuery', () => { + beforeEach(() => { + urlUtils.setUrlParams = jest.fn(); + urlUtils.visitUrl = jest.fn(); + }); + + it('calls visitUrl and setParams with the state.query', () => { + testAction(actions.applyQuery, null, state, [], [], () => { + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null }); + expect(urlUtils.visitUrl).toHaveBeenCalled(); + }); + }); + }); + + describe('resetQuery', () => { + beforeEach(() => { + urlUtils.setUrlParams = jest.fn(); + urlUtils.visitUrl = jest.fn(); + }); + + it('calls visitUrl and setParams with empty values', () => { + testAction(actions.resetQuery, null, state, [], [], () => { + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ + ...state.query, + page: null, + state: null, + confidential: null, + }); + expect(urlUtils.visitUrl).toHaveBeenCalled(); + }); + }); + }); }); describe('setQuery', () => { diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index 35c97d43eb0..247aff57c1a 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -17,6 +17,8 @@ import { returnUrl, mounts, project, + branch, + baseUrl, imageRoot, } from '../mock_data'; @@ -36,6 +38,8 @@ describe('~/static_site_editor/components/edit_area.vue', () => { returnUrl, mounts, project, + branch, + baseUrl, imageRoot, savingChanges, ...propsData, diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js index 4b27970023a..8bc65c6ce31 100644 --- a/spec/frontend/static_site_editor/mock_data.js +++ b/spec/frontend/static_site_editor/mock_data.js @@ -75,9 +75,17 @@ export const images = new Map([ export const mounts = [ { - source: 'some/source/', + source: 'default/source/', target: '', }, + { + source: 'source/with/target', + target: 'target', + }, ]; +export const branch = 'master'; + +export const baseUrl = '/user1/project1/-/sse/master%2Ftest.md'; + export const imageRoot = 'source/images/'; diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index fcd4fa66274..f5daa70714e 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -24,6 +24,8 @@ import { trackingCategory, images, mounts, + branch, + baseUrl, imageRoot, } from '../mock_data'; @@ -44,6 +46,8 @@ describe('static_site_editor/pages/home', () => { username, sourcePath, mounts, + branch, + baseUrl, imageUploadPath: imageRoot, }; const hasSubmittedChangesMutationPayload = { diff --git a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js index ab375d9e970..5ea90b8184f 100644 --- a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js +++ b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js @@ -1,11 +1,11 @@ import imageRenderer from '~/static_site_editor/services/renderers/render_image'; -import { mounts, project } from '../../mock_data'; +import { mounts, project, branch, baseUrl } from '../../mock_data'; describe('rich_content_editor/renderers/render_image', () => { let renderer; beforeEach(() => { - renderer = imageRenderer.build(mounts, project); + renderer = imageRenderer.build(mounts, project, branch, baseUrl); }); describe('build', () => { @@ -27,37 +27,38 @@ describe('rich_content_editor/renderers/render_image', () => { }); describe('render', () => { - let context; - let result; - const skipChildren = jest.fn(); - - beforeEach(() => { + it.each` + destination | isAbsolute | src + ${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'} + ${'/relative/path/to/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/default/source/relative/path/to/image.png'} + ${'/target/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/source/with/target/image.png'} + ${'relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/relative/to/current/image.png'} + ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'} + ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'} + `('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => { + const skipChildren = jest.fn(); + const context = { skipChildren }; const node = { - destination: '/some/path/image.png', + destination, firstChild: { type: 'img', literal: 'Some Image', }, }; + const result = renderer.render(node, context); - context = { skipChildren }; - result = renderer.render(node, context); - }); - - it('invokes `skipChildren`', () => { - expect(skipChildren).toHaveBeenCalled(); - }); - - it('returns an image', () => { expect(result).toEqual({ type: 'openTag', tagName: 'img', selfClose: true, attributes: { - src: '/some/path/image.png', + 'data-original-src': !isAbsolute ? destination : '', + src, alt: 'Some Image', }, }); + + expect(skipChildren).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js index fd745c21bb6..85516eae4cf 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -189,4 +189,30 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => { expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); }); }); + + describe('IMG', () => { + const originalSrc = 'path/to/image.png'; + const alt = 'alt text'; + let node; + + beforeEach(() => { + node = document.createElement('img'); + node.alt = alt; + node.src = originalSrc; + }); + + it('returns an image with its original src of the `original-src` attribute is preset', () => { + node.dataset.originalSrc = originalSrc; + node.src = 'modified/path/to/image.png'; + + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + + expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); + }); + + it('fallback to `src` if no `original-src` is specified on the image', () => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); + }); + }); }); diff --git a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap index b84f12df4f6..877cc78a111 100644 --- a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap +++ b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap @@ -9,12 +9,6 @@ exports[`WebIDE runs 1`] = ` class="ide-view flex-grow d-flex" > <div - class="file-finder-overlay" - style="display: none;" - > - (jest: contents hidden) - </div> - <div class="gl-relative multi-file-commit-panel flex-column" style="width: 340px;" > @@ -109,28 +103,12 @@ exports[`WebIDE runs 1`] = ` <h4> Make and review changes in the browser with the Web IDE </h4> - <div - class="gl-spinner-container" - > - <span - aria-label="Loading" - class="align-text-bottom gl-spinner gl-spinner-dark gl-spinner-md" - /> - </div> </div> </div> </div> </div> </div> </div> - <footer - class="ide-status-bar" - > - <div - class="ide-status-list d-flex ml-auto" - > - </div> - </footer> </article> </div> `; diff --git a/spec/helpers/operations_helper_spec.rb b/spec/helpers/operations_helper_spec.rb index 63f821da2bb..09f9bba8f9e 100644 --- a/spec/helpers/operations_helper_spec.rb +++ b/spec/helpers/operations_helper_spec.rb @@ -44,7 +44,8 @@ RSpec.describe OperationsHelper do 'prometheus_activated' => 'false', 'prometheus_url' => notify_project_prometheus_alerts_url(project, format: :json), 'disabled' => 'false', - 'project_path' => project.full_path + 'project_path' => project.full_path, + 'multi_integrations' => 'false' ) end end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index c57d345ef56..17ac7d0e44d 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -40,6 +40,46 @@ RSpec.describe UserPolicy do end end + describe "creating a different user's Personal Access Tokens" do + context 'when current_user is admin' do + let(:current_user) { create(:user, :admin) } + + context 'when admin mode is enabled and current_user is not blocked', :enable_admin_mode do + it { is_expected.to be_allowed(:create_user_personal_access_token) } + end + + context 'when admin mode is enabled and current_user is blocked', :enable_admin_mode do + let(:current_user) { create(:admin, :blocked) } + + it { is_expected.not_to be_allowed(:create_user_personal_access_token) } + end + + context 'when admin mode is disabled' do + it { is_expected.not_to be_allowed(:create_user_personal_access_token) } + end + end + + context 'when current_user is not an admin' do + context 'creating their own personal access tokens' do + subject { described_class.new(current_user, current_user) } + + context 'when current_user is not blocked' do + it { is_expected.to be_allowed(:create_user_personal_access_token) } + end + + context 'when current_user is blocked' do + let(:current_user) { create(:user, :blocked) } + + it { is_expected.not_to be_allowed(:create_user_personal_access_token) } + end + end + + context "creating a different user's personal access tokens" do + it { is_expected.not_to be_allowed(:create_user_personal_access_token) } + end + end + end + shared_examples 'changing a user' do |ability| context "when a regular user tries to destroy another regular user" do it { is_expected.not_to be_allowed(ability) } diff --git a/spec/services/personal_access_tokens/create_service_spec.rb b/spec/services/personal_access_tokens/create_service_spec.rb index 475ade95948..667ed337d83 100644 --- a/spec/services/personal_access_tokens/create_service_spec.rb +++ b/spec/services/personal_access_tokens/create_service_spec.rb @@ -3,21 +3,53 @@ require 'spec_helper' RSpec.describe PersonalAccessTokens::CreateService do + shared_examples_for 'a successfully created token' do + it 'creates personal access token record' do + expect(subject.success?).to be true + expect(token.name).to eq(params[:name]) + expect(token.impersonation).to eq(params[:impersonation]) + expect(token.scopes).to eq(params[:scopes]) + expect(token.expires_at).to eq(params[:expires_at]) + expect(token.user).to eq(user) + end + + it 'logs the event' do + expect(Gitlab::AppLogger).to receive(:info).with(/PAT CREATION: created_by: '#{current_user.username}', created_for: '#{user.username}', token_id: '\d+'/) + + subject + end + end + + shared_examples_for 'an unsuccessfully created token' do + it { expect(subject.success?).to be false } + it { expect(subject.message).to eq('Not permitted to create') } + it { expect(token).to be_nil } + end + describe '#execute' do - context 'with valid params' do - it 'creates personal access token record' do - user = create(:user) - params = { name: 'Test token', impersonation: true, scopes: [:api], expires_at: Date.today + 1.month } - - response = described_class.new(user, params).execute - personal_access_token = response.payload[:personal_access_token] - - expect(response.success?).to be true - expect(personal_access_token.name).to eq(params[:name]) - expect(personal_access_token.impersonation).to eq(params[:impersonation]) - expect(personal_access_token.scopes).to eq(params[:scopes]) - expect(personal_access_token.expires_at).to eq(params[:expires_at]) - expect(personal_access_token.user).to eq(user) + subject { service.execute } + + let(:current_user) { create(:user) } + let(:user) { create(:user) } + let(:params) { { name: 'Test token', impersonation: false, scopes: [:api], expires_at: Date.today + 1.month } } + let(:service) { described_class.new(current_user: current_user, target_user: user, params: params) } + let(:token) { subject.payload[:personal_access_token] } + + context 'when current_user is an administrator' do + let(:current_user) { create(:admin) } + + it_behaves_like 'a successfully created token' + end + + context 'when current_user is not an administrator' do + context 'target_user is not the same as current_user' do + it_behaves_like 'an unsuccessfully created token' + end + + context 'target_user is same as current_user' do + let(:current_user) { user } + + it_behaves_like 'a successfully created token' end end end diff --git a/spec/services/personal_access_tokens/revoke_service_spec.rb b/spec/services/personal_access_tokens/revoke_service_spec.rb index 5afa43cef76..b6563a6131b 100644 --- a/spec/services/personal_access_tokens/revoke_service_spec.rb +++ b/spec/services/personal_access_tokens/revoke_service_spec.rb @@ -6,6 +6,11 @@ RSpec.describe PersonalAccessTokens::RevokeService do shared_examples_for 'a successfully revoked token' do it { expect(subject.success?).to be true } it { expect(service.token.revoked?).to be true } + it 'logs the event' do + expect(Gitlab::AppLogger).to receive(:info).with(/PAT REVOCATION: revoked_by: '#{current_user.username}', revoked_for: '#{token.user.username}', token_id: '\d+'/) + + subject + end end shared_examples_for 'an unsuccessfully revoked token' do diff --git a/spec/support/helpers/cop_helper.rb b/spec/support/helpers/cop_helper.rb deleted file mode 100644 index d4c056eea8a..00000000000 --- a/spec/support/helpers/cop_helper.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -require 'tempfile' - -# This module provides methods that make it easier to test Cops. -module CopHelper - extend RSpec::SharedContext - - let(:ruby_version) { 2.4 } - let(:rails_version) { false } - - def inspect_source_file(source) - Tempfile.open('tmp') { |f| inspect_source(source, f) } - end - - def inspect_source(source, file = nil) - RuboCop::Formatter::DisabledConfigFormatter.config_to_allow_offenses = {} - RuboCop::Formatter::DisabledConfigFormatter.detected_styles = {} - processed_source = parse_source(source, file) - raise 'Error parsing example code' unless processed_source.valid_syntax? - - _investigate(cop, processed_source) - end - - def parse_source(source, file = nil) - if file&.respond_to?(:write) - file.write(source) - file.rewind - file = file.path - end - - RuboCop::ProcessedSource.new(source, ruby_version, file) - end - - def autocorrect_source_file(source) - Tempfile.open('tmp') { |f| autocorrect_source(source, f) } - end - - def autocorrect_source(source, file = nil) - RuboCop::Formatter::DisabledConfigFormatter.config_to_allow_offenses = {} - RuboCop::Formatter::DisabledConfigFormatter.detected_styles = {} - cop.instance_variable_get(:@options)[:auto_correct] = true - processed_source = parse_source(source, file) - _investigate(cop, processed_source) - - @last_corrector.rewrite - end - - def _investigate(cop, processed_source) - team = RuboCop::Cop::Team.new([cop], nil, raise_error: true) - report = team.investigate(processed_source) - @last_corrector = report.correctors.first || RuboCop::Cop::Corrector.new(processed_source) - report.offenses - end -end - -module RuboCop - module Cop - # Monkey-patch Cop for tests to provide easy access to messages and - # highlights. - # this file is an exact copy of source except for this line - # where we change to the new Base class defined in rubocop and skirt around our superclass mismatch for class Cop - # when running a rubocop spec. - class Base - def messages - offenses.sort.map(&:message) - end - - def highlights - offenses.sort.map { |o| o.location.source } - end - end - end -end diff --git a/spec/support/rspec.rb b/spec/support/rspec.rb index 558a6ee1e86..32f738faa9b 100644 --- a/spec/support/rspec.rb +++ b/spec/support/rspec.rb @@ -6,7 +6,13 @@ require_relative "helpers/stub_object_storage" require_relative "helpers/stub_env" require_relative "helpers/fast_rails_root" -require_relative 'rubocop_patch' +# so we need to load rubocop here due to the rubocop support file loading cop_helper +# which monkey patches class Cop +# if cop helper is loaded before rubocop (where class Cop is defined as class Cop < Base) +# we get a `superclass mismatch for class Cop` error when running a rspec for a locally defined +# rubocop cop - therefore we need rubocop required first since it had an inheritance added to the Cop class +require 'rubocop' +require 'rubocop/rspec/support' RSpec.configure do |config| config.mock_with :rspec diff --git a/spec/support/rubocop_patch.rb b/spec/support/rubocop_patch.rb deleted file mode 100644 index f485ef32a87..00000000000 --- a/spec/support/rubocop_patch.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# There is an issue between rubocop versions 0.86 and 0.87 (verified by testing locally) -# where the monkey patching in cop_helper is referencing class Cop and should really be referencing class Base instead -# the gem's version of the cop_helper causes this issue when testing a rubocop cop locally and in CI -# The only difference in this file as compared to gem source file is that we include our own cop_helper instead -# which is a direct copy with a fix for the monkey patching part. -# Doing this, resolves the issue. -# Ideally we should move away from using the cop_helper at all as is the direction of rubocop as seen -# here - https://github.com/rubocop-hq/rubocop/issues/8003 -# more info https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46477 - -require_relative 'helpers/cop_helper' -require 'rubocop/rspec/host_environment_simulation_helper' -require 'rubocop/rspec/shared_contexts' -require 'rubocop/rspec/expect_offense' - -RSpec.configure do |config| - config.include CopHelper - config.include HostEnvironmentSimulatorHelper -end diff --git a/spec/support/shared_examples/serializers/note_entity_shared_examples.rb b/spec/support/shared_examples/serializers/note_entity_shared_examples.rb index a90a2dc3667..42cda3059e6 100644 --- a/spec/support/shared_examples/serializers/note_entity_shared_examples.rb +++ b/spec/support/shared_examples/serializers/note_entity_shared_examples.rb @@ -20,6 +20,39 @@ RSpec.shared_examples 'note entity' do it 'does not expose web_url for author' do expect(subject[:author]).not_to include(:web_url) end + + it 'exposes permission fields on current_user' do + expect(subject[:current_user]).to include(:can_edit, :can_award_emoji, :can_resolve, :can_resolve_discussion) + end + + describe ':can_resolve_discussion' do + context 'discussion is resolvable' do + before do + expect(note.discussion).to receive(:resolvable?).and_return(true) + end + + context 'user can resolve' do + it 'is true' do + expect(note.discussion).to receive(:can_resolve?).with(user).and_return(true) + expect(subject[:current_user][:can_resolve_discussion]).to be_truthy + end + end + + context 'user cannot resolve' do + it 'is false' do + expect(note.discussion).to receive(:can_resolve?).with(user).and_return(false) + expect(subject[:current_user][:can_resolve_discussion]).to be_falsey + end + end + end + + context 'discussion is not resolvable' do + it 'is false' do + expect(note.discussion).to receive(:resolvable?).and_return(false) + expect(subject[:current_user][:can_resolve_discussion]).to be_falsey + end + end + end end context 'when note was edited' do diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb index 58912eab51e..6299fd0cf36 100644 --- a/spec/views/search/_results.html.haml_spec.rb +++ b/spec/views/search/_results.html.haml_spec.rb @@ -43,7 +43,7 @@ RSpec.describe 'search/_results' do let_it_be(:wiki_blob) { create(:wiki_page, project: project, content: '*') } let_it_be(:user) { create(:admin) } - %w[issues blobs notes wiki_blobs merge_requests milestones].each do |search_scope| + %w[issues merge_requests].each do |search_scope| context "when scope is #{search_scope}" do let(:scope) { search_scope } let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) } @@ -55,16 +55,30 @@ RSpec.describe 'search/_results' do expect(rendered).to have_selector('[data-track-property=search_result]') end - it 'renders the state filter drop down' do + it 'does render the sidebar' do render - expect(rendered).to have_selector('#js-search-filter-by-state') + expect(rendered).to have_selector('#js-search-sidebar') + end + end + end + + %w[blobs notes wiki_blobs milestones].each do |search_scope| + context "when scope is #{search_scope}" do + let(:scope) { search_scope } + let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) } + + it 'renders the click text event tracking attributes' do + render + + expect(rendered).to have_selector('[data-track-event=click_text]') + expect(rendered).to have_selector('[data-track-property=search_result]') end - it 'renders the confidential drop down' do + it 'does not render the sidebar' do render - expect(rendered).to have_selector('#js-search-filter-by-confidential') + expect(rendered).not_to have_selector('#js-search-sidebar') end end end diff --git a/spec/workers/remove_expired_members_worker_spec.rb b/spec/workers/remove_expired_members_worker_spec.rb index 8a34b41834b..5642de05731 100644 --- a/spec/workers/remove_expired_members_worker_spec.rb +++ b/spec/workers/remove_expired_members_worker_spec.rb @@ -31,6 +31,50 @@ RSpec.describe RemoveExpiredMembersWorker do end end + context 'project bots' do + let(:project) { create(:project) } + + context 'expired project bot', :sidekiq_inline do + let_it_be(:expired_project_bot) { create(:user, :project_bot) } + + before do + project.add_user(expired_project_bot, :maintainer, expires_at: 1.day.from_now) + travel_to(3.days.from_now) + end + + it 'removes expired project bot membership' do + expect { worker.perform }.to change { Member.count }.by(-1) + expect(Member.find_by(user_id: expired_project_bot.id)).to be_nil + end + + it 'deletes expired project bot' do + worker.perform + + expect(User.exists?(expired_project_bot.id)).to be(false) + end + end + + context 'non-expired project bot' do + let_it_be(:other_project_bot) { create(:user, :project_bot) } + + before do + project.add_user(other_project_bot, :maintainer, expires_at: 10.days.from_now) + travel_to(3.days.from_now) + end + + it 'does not remove expired project bot that expires in the future' do + expect { worker.perform }.to change { Member.count }.by(0) + expect(other_project_bot.reload).to be_present + end + + it 'does not delete project bot expiring in the future' do + worker.perform + + expect(User.exists?(other_project_bot.id)).to be(true) + end + end + end + context 'group members' do let_it_be(:expired_group_member) { create(:group_member, expires_at: 1.day.from_now, access_level: GroupMember::DEVELOPER) } let_it_be(:group_member_expiring_in_future) { create(:group_member, expires_at: 10.days.from_now, access_level: GroupMember::DEVELOPER) } |