diff options
102 files changed, 1527 insertions, 385 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js index 83f2ca0bdc2..d712c90242c 100644 --- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js +++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js @@ -14,6 +14,7 @@ export default function initGFMInput($els) { milestones: enableGFM, mergeRequests: enableGFM, labels: enableGFM, + vulnerabilities: enableGFM, }); }); } diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue index e3b57d5a7f7..072dd87861a 100644 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable vue/require-default-prop */ import IssueCardInner from './issue_card_inner.vue'; import boardsStore from '../stores/boards_store'; @@ -19,11 +18,6 @@ export default { default: () => ({}), required: false, }, - issueLinkBase: { - type: String, - default: '', - required: false, - }, disabled: { type: Boolean, default: false, @@ -34,15 +28,6 @@ export default { default: 0, required: false, }, - rootPath: { - type: String, - default: '', - required: false, - }, - groupId: { - type: Number, - required: false, - }, isActive: { type: Boolean, required: false, @@ -103,13 +88,6 @@ export default { @mousemove="mouseMove" @mouseup="showIssue($event)" > - <issue-card-inner - :list="list" - :issue="issue" - :issue-link-base="issueLinkBase" - :group-id="groupId" - :root-path="rootPath" - :update-filters="true" - /> + <issue-card-inner :list="list" :issue="issue" :update-filters="true" /> </li> </template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 23e4edea40f..6d216911798 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -34,27 +34,15 @@ export default { type: Boolean, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - boardId: { - type: String, - required: true, - }, canAdminList: { type: Boolean, required: false, default: false, }, - groupId: { - type: Number, - required: false, - default: null, + }, + inject: { + boardId: { + type: String, }, }, data() { @@ -151,22 +139,14 @@ export default { <div class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" > - <board-list-header - :can-admin-list="canAdminList" - :list="list" - :disabled="disabled" - :board-id="boardId" - /> + <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> <board-list v-if="showBoardListAndBoardInfo" ref="board-list" :disabled="disabled" - :group-id="groupId || null" - :issue-link-base="issueLinkBase" :issues="listIssues" :list="list" :loading="list.loading" - :root-path="rootPath" /> <board-blank-state v-if="canAdminList && list.id === 'blank'" /> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index d3c0e160153..c7b3da0e672 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -21,27 +21,10 @@ export default { type: Boolean, required: true, }, - groupId: { - type: Number, - required: false, - default: null, - }, disabled: { type: Boolean, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - boardId: { - type: String, - required: true, - }, }, computed: { ...mapState(['boardLists', 'error']), @@ -77,12 +60,8 @@ export default { :key="list.id" ref="board" :can-admin-list="canAdminList" - :group-id="groupId" :list="list" :disabled="disabled" - :issue-link-base="issueLinkBase" - :root-path="rootPath" - :board-id="boardId" /> </div> @@ -92,9 +71,6 @@ export default { :lists="boardLists" :can-admin-list="canAdminList" :disabled="disabled" - :board-id="boardId" - :group-id="groupId" - :root-path="rootPath" /> <board-content-sidebar /> </template> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 6b19f7f3353..25f8ffca633 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -27,11 +27,6 @@ export default { }, mixins: [glFeatureFlagMixin()], props: { - groupId: { - type: Number, - required: false, - default: 0, - }, disabled: { type: Boolean, required: true, @@ -48,14 +43,6 @@ export default { type: Boolean, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, }, data() { return { @@ -435,11 +422,7 @@ export default { <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')"> <gl-loading-icon /> </div> - <board-new-issue - v-if="list.type !== 'closed' && showIssueForm" - :group-id="groupId" - :list="list" - /> + <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> <ul v-show="!loading" ref="list" @@ -455,9 +438,6 @@ export default { :index="index" :list="list" :issue="issue" - :issue-link-base="issueLinkBase" - :group-id="groupId" - :root-path="rootPath" :disabled="disabled" /> <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 0f27961cb3f..361fe252afb 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -45,10 +45,6 @@ export default { type: Boolean, required: true, }, - boardId: { - type: String, - required: true, - }, canAdminList: { type: Boolean, required: false, @@ -60,6 +56,11 @@ export default { default: false, }, }, + inject: { + boardId: { + type: String, + }, + }, data() { return { weightFeatureAvailable: false, diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 924874d4dd2..348d485ff37 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -17,16 +17,16 @@ export default { }, mixins: [glFeatureFlagMixin()], props: { - groupId: { - type: Number, - required: false, - default: 0, - }, list: { type: Object, required: true, }, }, + inject: { + groupId: { + type: Number, + }, + }, data() { return { title: '', @@ -139,7 +139,7 @@ export default { <project-select v-if="groupId" :group-id="groupId" :list="list" /> <div class="clearfix gl-mt-3"> <gl-button - ref="submit-button" + ref="submitButton" :disabled="disabled" class="float-left" variant="success" @@ -147,9 +147,14 @@ export default { type="submit" >{{ __('Submit issue') }}</gl-button > - <gl-button class="float-right" type="button" variant="default" @click="cancel">{{ - __('Cancel') - }}</gl-button> + <gl-button + ref="cancelButton" + class="float-right" + type="button" + variant="default" + @click="cancel" + >{{ __('Cancel') }}</gl-button + > </div> </form> </div> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 3184983ff22..8658f51e5cf 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -30,28 +30,23 @@ export default { type: Object, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, list: { type: Object, required: false, default: () => ({}), }, - rootPath: { - type: String, - required: true, - }, updateFilters: { type: Boolean, required: false, default: false, }, + }, + inject: { groupId: { type: Number, - required: false, - default: null, + }, + rootPath: { + type: String, }, }, data() { diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index fb2d7b6dbc5..817b3bdddb0 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -26,14 +26,6 @@ export default { type: String, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, projectId: { type: Number, required: true, @@ -146,12 +138,7 @@ export default { > <div class="add-issues-container d-flex flex-column m-auto rounded"> <modal-header :project-id="projectId" :label-path="labelPath" /> - <modal-list - v-if="!loading && showList && !filterLoading" - :issue-link-base="issueLinkBase" - :root-path="rootPath" - :empty-state-svg="emptyStateSvg" - /> + <modal-list v-if="!loading && showList && !filterLoading" :empty-state-svg="emptyStateSvg" /> <empty-state v-if="showEmptyState" :new-issue-path="newIssuePath" diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index 9b8562f2ce5..219263bd9b9 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -10,14 +10,6 @@ export default { GlIcon, }, props: { - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, emptyStateSvg: { type: String, required: true, @@ -134,7 +126,7 @@ export default { class="board-card position-relative p-3 rounded" @click="toggleIssue($event, issue)" > - <issue-card-inner :issue="issue" :issue-link-base="issueLinkBase" :root-path="rootPath" /> + <issue-card-inner :issue="issue" /> <gl-icon v-if="issue.selected" :aria-label="'Issue #' + issue.id + ' selected'" diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index b4657ffcc35..59e7620962a 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -15,16 +15,16 @@ export default { GlLoadingIcon, }, props: { - groupId: { - type: Number, - required: true, - default: 0, - }, list: { type: Object, required: true, }, }, + inject: { + groupId: { + type: Number, + }, + }, data() { return { loading: true, diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 2f3a0c7351d..1173c6d0578 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -82,12 +82,13 @@ export default () => { BoardAddIssuesModal, BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'), }, - store, - apolloProvider, provide: { - // TODO: Mv all non-reactive props from data/props to here. + boardId: $boardApp.dataset.boardId, + groupId: Number($boardApp.dataset.groupId) || null, rootPath: $boardApp.dataset.rootPath, }, + store, + apolloProvider, data() { return { state: boardsStore.state, @@ -95,10 +96,7 @@ export default () => { boardsEndpoint: $boardApp.dataset.boardsEndpoint, recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, listsEndpoint: $boardApp.dataset.listsEndpoint, - boardId: $boardApp.dataset.boardId, disabled: parseBoolean($boardApp.dataset.disabled), - issueLinkBase: $boardApp.dataset.issueLinkBase, - rootPath: $boardApp.dataset.rootPath, bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, detailIssue: boardsStore.detail, parent: $boardApp.dataset.parent, @@ -116,7 +114,7 @@ export default () => { recentBoardsEndpoint: this.recentBoardsEndpoint, listsEndpoint: this.listsEndpoint, bulkUpdatePath: this.bulkUpdatePath, - boardId: this.boardId, + boardId: $boardApp.dataset.boardId, fullPath: $boardApp.dataset.fullPath, }; this.setInitialBoardData({ diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 3e6b223ca93..409733c73b9 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -52,6 +52,7 @@ export const defaultAutocompleteConfig = { milestones: true, labels: true, snippets: true, + vulnerabilities: true, }; class GfmAutoComplete { @@ -647,7 +648,8 @@ class GfmAutoComplete { // https://github.com/ichord/At.js const atSymbolsWithBar = Object.keys(controllers) .join('|') - .replace(/[$]/, '\\$&'); + .replace(/[$]/, '\\$&') + .replace(/[+]/, '\\+'); const atSymbolsWithoutBar = Object.keys(controllers).join(''); const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop(); const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); @@ -678,6 +680,7 @@ GfmAutoComplete.atTypeMap = { '~': 'labels', '%': 'milestones', '/': 'commands', + '+': 'vulnerabilities', $: 'snippets', }; diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index 1354fdc3d98..caa122f6ed2 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -2,7 +2,7 @@ import { mapGetters } from 'vuex'; import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; -import { getFileEOL } from '../utils'; +import { isTextFile, getFileEOL } from '~/ide/utils'; export default { components: { @@ -17,6 +17,9 @@ export default { activeFileEOL() { return getFileEOL(this.activeFile.content); }, + activeFileIsText() { + return isTextFile(this.activeFile); + }, }, }; </script> @@ -30,7 +33,7 @@ export default { </gl-link> </div> <div>{{ activeFileEOL }}</div> - <div v-if="!activeFile.binary">{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}</div> + <div v-if="activeFileIsText">{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}</div> <div>{{ activeFile.fileLanguage }}</div> </template> <terminal-sync-status-safe /> diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index b2141c13d9f..84ff05c9750 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -28,14 +28,13 @@ export default { const { name } = file; const encodedContent = target.result.split('base64,')[1]; const rawContent = encodedContent ? atob(encodedContent) : ''; - const isText = isTextFile(rawContent, file.type, name); + const isText = isTextFile({ content: rawContent, mimeType: file.type, name }); const emitCreateEvent = content => this.$emit('create', { name: `${this.path ? `${this.path}/` : ''}${name}`, type: 'blob', content, - binary: !isText, rawPath: !isText ? target.result : '', }); diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 52e4f6704c6..f342ce1739c 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -14,7 +14,7 @@ import Editor from '../lib/editor'; import FileTemplatesBar from './file_templates/bar.vue'; import { __ } from '~/locale'; import { extractMarkdownImagesFromEntries } from '../stores/utils'; -import { getPathParent, readFileAsDataURL, registerSchema } from '../utils'; +import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils'; import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; @@ -60,7 +60,7 @@ export default { ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), shouldHideEditor() { - return this.file && this.file.binary; + return this.file && !isTextFile(this.file); }, showContentViewer() { return ( diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index b02f213d922..789e09fa8f2 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -1,4 +1,3 @@ -import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import { decorateData, sortTree } from '../stores/utils'; export const splitParent = path => { @@ -13,13 +12,7 @@ export const splitParent = path => { /** * Create file objects from a list of file paths. */ -export const decorateFiles = ({ - data, - tempFile = false, - content = '', - binary = false, - rawPath = '', -}) => { +export const decorateFiles = ({ data, tempFile = false, content = '', rawPath = '' }) => { const treeList = []; const entries = {}; @@ -68,7 +61,6 @@ export const decorateFiles = ({ const fileFolder = parent && insertParent(parent); if (name) { - const previewMode = viewerInformationForPath(name); parentPath = fileFolder && fileFolder.path; file = decorateData({ @@ -79,7 +71,6 @@ export const decorateFiles = ({ tempFile, changed: tempFile, content, - binary: (previewMode && previewMode.binary) || binary, rawPath, parentPath, }); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index a8fccdc7695..b8d59f8bd36 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -25,15 +25,7 @@ export const setResizingStatus = ({ commit }, resizing) => { export const createTempEntry = ( { state, commit, dispatch, getters }, - { - name, - type, - content = '', - binary = false, - rawPath = '', - openFile = true, - makeFileActive = true, - }, + { name, type, content = '', rawPath = '', openFile = true, makeFileActive = true }, ) => { const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; @@ -57,7 +49,6 @@ export const createTempEntry = ( type, tempFile: true, content, - binary, rawPath, }); const { file, parentPath } = data; @@ -84,7 +75,6 @@ export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) => name: getters.getAvailableFileName(name), type: 'blob', content: rawPath.split('base64,')[1], - binary: true, rawPath, openFile: false, makeFileActive: false, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 56e2e2aa53f..d9cdc7727ad 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -23,7 +23,6 @@ export const dataStructure = () => ({ staged: false, lastCommitSha: '', rawPath: '', - binary: false, raw: '', content: '', editorRow: 1, @@ -49,7 +48,6 @@ export const decorateData = entity => { active = false, opened = false, changed = false, - binary = false, rawPath = '', file_lock, parentPath = '', @@ -66,7 +64,6 @@ export const decorateData = entity => { active, changed, content, - binary, rawPath, file_lock, parentPath, diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index ffc6e4c66f0..cde53e1ef00 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,5 +1,5 @@ import { languages } from 'monaco-editor'; -import { flatten } from 'lodash'; +import { flatten, isString } from 'lodash'; import { SIDE_LEFT, SIDE_RIGHT } from './constants'; const toLowerCase = x => x.toLowerCase(); @@ -42,15 +42,16 @@ const KNOWN_TYPES = [ }, ]; -export function isTextFile(content, mimeType, fileName) { - const knownType = KNOWN_TYPES.find(type => type.isMatch(mimeType, fileName)); +export function isTextFile({ name, content, mimeType = '' }) { + const knownType = KNOWN_TYPES.find(type => type.isMatch(mimeType, name)); if (knownType) return knownType.isText; // does the string contain ascii characters only (ranges from space to tilde, tabs and new lines) const asciiRegex = /^[ -~\t\n\r]+$/; + // for unknown types, determine the type by evaluating the file contents - return asciiRegex.test(content); + return isString(content) && (content === '' || asciiRegex.test(content)); } export const createPathWithExt = p => { diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 17f910a9d75..0460ed6791e 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -25,7 +25,7 @@ export default { mixins: [glFeatureFlagsMixin()], computed: { ...mapGetters(['currentKey', 'propsSource', 'isSavingOrTesting']), - ...mapState(['adminState', 'override', 'isSaving', 'isTesting']), + ...mapState(['defaultState', 'override', 'isSaving', 'isTesting']), isEditable() { return this.propsSource.editable; }, @@ -53,8 +53,8 @@ export default { <template> <div> <override-dropdown - v-if="adminState !== null" - :inherit-from-id="adminState.id" + v-if="defaultState !== null" + :inherit-from-id="defaultState.id" :override="override" :learn-more-path="propsSource.learnMorePath" @change="setOverride" diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue index 5e1ff96c5d7..c31dada8d2f 100644 --- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue +++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue @@ -44,9 +44,9 @@ export default { }; }, computed: { - ...mapState(['adminState']), + ...mapState(['defaultState']), description() { - const level = this.adminState.integrationLevel; + const level = this.defaultState.integrationLevel; return ( overrideDropdownDescriptions[level] || overrideDropdownDescriptions[defaultIntegrationLevel] diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 5413226078d..248ee62d43a 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -72,7 +72,7 @@ function parseDatasetToProps(data) { }; } -export default (el, adminEl) => { +export default (el, defaultEl) => { if (!el) { return null; } @@ -80,12 +80,12 @@ export default (el, adminEl) => { const props = parseDatasetToProps(el.dataset); const initialState = { - adminState: null, + defaultState: null, customState: props, }; - if (adminEl) { - initialState.adminState = Object.freeze(parseDatasetToProps(adminEl.dataset)); + if (defaultEl) { + initialState.defaultState = Object.freeze(parseDatasetToProps(defaultEl.dataset)); } return new Vue({ diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js index 4b494c30845..4ee5f11855c 100644 --- a/app/assets/javascripts/integrations/edit/store/getters.js +++ b/app/assets/javascripts/integrations/edit/store/getters.js @@ -1,8 +1,8 @@ -export const isInheriting = state => (state.adminState === null ? false : !state.override); +export const isInheriting = state => (state.defaultState === null ? false : !state.override); export const isSavingOrTesting = state => state.isSaving || state.isTesting; export const propsSource = (state, getters) => - getters.isInheriting ? state.adminState : state.customState; + getters.isInheriting ? state.defaultState : state.customState; export const currentKey = (state, getters) => (getters.isInheriting ? 'admin' : 'custom'); diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js index 7f1cd5c6e4d..a9ecee6c539 100644 --- a/app/assets/javascripts/integrations/edit/store/state.js +++ b/app/assets/javascripts/integrations/edit/store/state.js @@ -1,9 +1,9 @@ -export default ({ adminState = null, customState = {} } = {}) => { - const override = adminState !== null ? adminState.id !== customState.inheritFromId : false; +export default ({ defaultState = null, customState = {} } = {}) => { + const override = defaultState !== null ? defaultState.id !== customState.inheritFromId : false; return { override, - adminState, + defaultState, customState, isSaving: false, isTesting: false, diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 529358a5f0b..1d0814125e6 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -20,7 +20,7 @@ export default class IntegrationSettingsForm { // Init Vue component this.vue = initForm( document.querySelector('.js-vue-integration-settings'), - document.querySelector('.js-vue-admin-integration-settings'), + document.querySelector('.js-vue-default-integration-settings'), ); eventHub.$on('toggle', active => { this.formActive = active; diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/issuable_create/components/issuable_form.vue index eac4050b53d..17e51b3dbac 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_form.vue +++ b/app/assets/javascripts/issuable_create/components/issuable_form.vue @@ -53,7 +53,12 @@ export default { <div data-testid="issuable-title" class="form-group row"> <label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label> <div class="col-sm-10"> - <gl-form-input id="issuable-title" v-model="issuableTitle" :placeholder="__('Title')" /> + <gl-form-input + id="issuable-title" + v-model="issuableTitle" + :autofocus="true" + :placeholder="__('Title')" + /> </div> </div> <div data-testid="issuable-description" class="form-group row"> diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue new file mode 100644 index 00000000000..d8cb1ab07cd --- /dev/null +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -0,0 +1,140 @@ +<script> +import { GlLink, GlLabel, GlTooltipDirective } from '@gitlab/ui'; + +import { __, sprintf } from '~/locale'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + components: { + GlLink, + GlLabel, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + issuableSymbol: { + type: String, + required: true, + }, + issuable: { + type: Object, + required: true, + }, + }, + computed: { + author() { + return this.issuable.author; + }, + authorId() { + const id = parseInt(this.author.id, 10); + + if (Number.isNaN(id)) { + return this.author.id.includes('gid') + ? this.author.id.split('gid://gitlab/User/').pop() + : ''; + } + + return id; + }, + labels() { + return this.issuable.labels?.nodes || this.issuable.labels || []; + }, + createdAt() { + return sprintf(__('created %{timeAgo}'), { + timeAgo: getTimeago().format(this.issuable.createdAt), + }); + }, + updatedAt() { + return sprintf(__('updated %{timeAgo}'), { + timeAgo: getTimeago().format(this.issuable.updatedAt), + }); + }, + }, + methods: { + scopedLabel(label) { + return isScopedLabel(label); + }, + /** + * This is needed as an independent method since + * when user changes current page, `$refs.authorLink` + * will be null until next page results are loaded & rendered. + */ + getAuthorPopoverTarget() { + if (this.$refs.authorLink) { + return this.$refs.authorLink.$el; + } + return ''; + }, + }, +}; +</script> + +<template> + <li class="issue"> + <div class="issue-box"> + <div class="issuable-info-container"> + <div class="issuable-main-info"> + <div data-testid="issuable-title" class="issue-title title"> + <span class="issue-title-text" dir="auto"> + <gl-link :href="issuable.webUrl">{{ issuable.title }}</gl-link> + </span> + </div> + <div class="issuable-info"> + <span data-testid="issuable-reference" class="issuable-reference" + >{{ issuableSymbol }}{{ issuable.iid }}</span + > + <span class="issuable-authored d-none d-sm-inline-block"> + · + <span + v-gl-tooltip:tooltipcontainer.bottom + data-testid="issuable-created-at" + :title="tooltipTitle(issuable.createdAt)" + >{{ createdAt }}</span + > + {{ __('by') }} + <gl-link + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :data-avatar-url="author.avatarUrl" + :href="author.webUrl" + data-testid="issuable-author" + class="author-link js-user-link" + > + <span class="author">{{ author.name }}</span> + </gl-link> + </span> + + <gl-label + v-for="(label, index) in labels" + :key="index" + :background-color="label.color" + :title="label.title" + :description="label.description" + :scoped="scopedLabel(label)" + :class="{ 'gl-ml-2': index }" + size="sm" + /> + </div> + </div> + <div class="issuable-meta"> + <div + data-testid="issuable-updated-at" + class="float-right issuable-updated-at d-none d-sm-inline-block" + > + <span + v-gl-tooltip:tooltipcontainer.bottom + :title="tooltipTitle(issuable.updatedAt)" + class="issuable-updated-at" + >{{ updatedAt }}</span + > + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue new file mode 100644 index 00000000000..7535203dea1 --- /dev/null +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -0,0 +1,153 @@ +<script> +import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; + +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +import IssuableTabs from './issuable_tabs.vue'; +import IssuableItem from './issuable_item.vue'; + +export default { + components: { + GlLoadingIcon, + IssuableTabs, + FilteredSearchBar, + IssuableItem, + GlPagination, + }, + props: { + namespace: { + type: String, + required: true, + }, + recentSearchesStorageKey: { + type: String, + required: true, + }, + searchInputPlaceholder: { + type: String, + required: true, + }, + searchTokens: { + type: Array, + required: true, + }, + sortOptions: { + type: Array, + required: true, + }, + initialFilterValue: { + type: Array, + required: false, + default: () => [], + }, + initialSortBy: { + type: String, + required: false, + default: 'created_desc', + }, + issuables: { + type: Array, + required: true, + }, + tabs: { + type: Array, + required: true, + }, + tabCounts: { + type: Object, + required: true, + }, + currentTab: { + type: String, + required: true, + }, + issuableSymbol: { + type: String, + required: false, + default: '#', + }, + issuablesLoading: { + type: Boolean, + required: false, + default: false, + }, + showPaginationControls: { + type: Boolean, + required: false, + default: false, + }, + defaultPageSize: { + type: Number, + required: false, + default: 20, + }, + currentPage: { + type: Number, + required: false, + default: 1, + }, + previousPage: { + type: Number, + required: false, + default: 0, + }, + nextPage: { + type: Number, + required: false, + default: 2, + }, + }, +}; +</script> + +<template> + <div class="issuable-list-container"> + <issuable-tabs + :tabs="tabs" + :tab-counts="tabCounts" + :current-tab="currentTab" + @click="$emit('click-tab', $event)" + > + <template #nav-actions> + <slot name="nav-actions"></slot> + </template> + </issuable-tabs> + <filtered-search-bar + :namespace="namespace" + :recent-searches-storage-key="recentSearchesStorageKey" + :search-input-placeholder="searchInputPlaceholder" + :tokens="searchTokens" + :sort-options="sortOptions" + :initial-filter-value="initialFilterValue" + :initial-sort-by="initialSortBy" + class="gl-flex-grow-1 row-content-block" + @onFilter="$emit('filter', $event)" + @onSort="$emit('sort', $event)" + /> + <div class="issuables-holder"> + <gl-loading-icon v-if="issuablesLoading" size="md" class="gl-mt-5" /> + <ul + v-if="!issuablesLoading && issuables.length" + class="content-list issuable-list issues-list" + > + <issuable-item + v-for="issuable in issuables" + :key="issuable.id" + :issuable-symbol="issuableSymbol" + :issuable="issuable" + /> + </ul> + <slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot> + <gl-pagination + v-if="showPaginationControls" + :per-page="defaultPageSize" + :value="currentPage" + :prev-page="previousPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="$emit('page-change', $event)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue new file mode 100644 index 00000000000..df544ce69e7 --- /dev/null +++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue @@ -0,0 +1,53 @@ +<script> +import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlTabs, + GlTab, + GlBadge, + }, + props: { + tabs: { + type: Array, + required: true, + }, + tabCounts: { + type: Object, + required: true, + }, + currentTab: { + type: String, + required: true, + }, + }, + methods: { + isTabActive(tabName) { + return tabName === this.currentTab; + }, + }, +}; +</script> + +<template> + <div class="top-area"> + <gl-tabs class="nav-links mobile-separator issuable-state-filters"> + <gl-tab + v-for="tab in tabs" + :key="tab.id" + :active="isTabActive(tab.name)" + @click="$emit('click', tab.name)" + > + <template #title> + <span :title="tab.titleTooltip">{{ tab.title }}</span> + <gl-badge variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{ + tabCounts[tab.name] + }}</gl-badge> + </template> + </gl-tab> + </gl-tabs> + <div class="nav-controls"> + <slot name="nav-actions"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js index 0ff84dc4667..9ee02f923d5 100644 --- a/app/assets/javascripts/shared/milestones/form.js +++ b/app/assets/javascripts/shared/milestones/form.js @@ -16,5 +16,6 @@ export default (initGFM = true) => { milestones: initGFM, labels: initGFM, snippets: initGFM, + vulnerabilities: initGFM, }); }; diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js index b0d373b1a4b..3dc74922a77 100644 --- a/app/assets/javascripts/snippet/snippet_edit.js +++ b/app/assets/javascripts/snippet/snippet_edit.js @@ -14,6 +14,7 @@ document.addEventListener('DOMContentLoaded', () => { milestones: false, labels: false, snippets: false, + vulnerabilities: false, }; const projectSnippetOptions = {}; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 9e1943f88b0..a48c279d0e3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -174,6 +174,7 @@ export default { milestones: this.enableAutocomplete, labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, snippets: this.enableAutocomplete, + vulnerabilities: this.enableAutocomplete, }); }, beforeDestroy() { diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb index 38f6b8dee65..e8551a7f270 100644 --- a/app/controllers/groups/settings/integrations_controller.rb +++ b/app/controllers/groups/settings/integrations_controller.rb @@ -12,7 +12,7 @@ module Groups end def edit - @admin_integration = Service.default_integration(integration.type, group) + @default_integration = Service.default_integration(integration.type, group) super end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d353062b6dc..54d17701a6e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -344,10 +344,12 @@ class Projects::IssuesController < Projects::ApplicationController def finder_options options = super - return options unless service_desk? + options[:issue_types] = Issue::TYPES_FOR_LIST - options.reject! { |key| key == 'author_username' || key == 'author_id' } - options[:author_id] = User.support_bot + if service_desk? + options.reject! { |key| key == 'author_username' || key == 'author_id' } + options[:author_id] = User.support_bot + end options end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index f2fdb9defac..9a69ef991dd 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -20,7 +20,7 @@ class Projects::ServicesController < Projects::ApplicationController layout "project_settings" def edit - @admin_integration = Service.default_integration(service.type, project) + @default_integration = Service.default_integration(service.type, project) end def update diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index f8c00f3a4cd..6a4a7a8dfb2 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -11,13 +11,13 @@ module BoardsHelper lists_endpoint: board_lists_path(board), board_id: board.id, disabled: (!can?(current_user, :create_non_backlog_issues, board)).to_s, - issue_link_base: build_issue_link_base, root_path: root_path, full_path: full_path, bulk_update_path: @bulk_issues_path, time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s, recent_boards_endpoint: recent_boards_path, - parent: current_board_parent.model_name.param_key + parent: current_board_parent.model_name.param_key, + group_id: @group&.id } end diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb index d6617b8c2eb..e6f02f2e4f3 100644 --- a/app/models/ci/daily_build_group_report_result.rb +++ b/app/models/ci/daily_build_group_report_result.rb @@ -11,6 +11,8 @@ module Ci validates :data, json_schema: { filename: "daily_build_group_report_result_data" } + scope :with_included_projects, -> { includes(:project) } + def self.upsert_reports(data) upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any? end diff --git a/app/models/issue.rb b/app/models/issue.rb index 73e22a00db0..5a5de371301 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -30,6 +30,11 @@ class Issue < ApplicationRecord SORTING_PREFERENCE_FIELD = :issues_sort + # Types of issues that should be displayed on lists across the app + # for example, project issues list, group issues list and issue boards. + # Some issue types, like test cases, should be hidden by default. + TYPES_FOR_LIST = %w(issue incident).freeze + belongs_to :project has_one :namespace, through: :project diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 594c822c18f..599c174ddd7 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -38,9 +38,9 @@ class ProtectedBranch < ApplicationRecord project.protected_branches end + # overridden in EE def self.branch_requires_code_owner_approval?(project, branch_name) - # NOOP - # + false end def self.by_name(query) diff --git a/app/services/projects/open_issues_count_service.rb b/app/services/projects/open_issues_count_service.rb index 82632d63e5b..dc450311db2 100644 --- a/app/services/projects/open_issues_count_service.rb +++ b/app/services/projects/open_issues_count_service.rb @@ -68,10 +68,12 @@ module Projects # Check https://gitlab.com/gitlab-org/gitlab-foss/issues/38418 description. # rubocop: disable CodeReuse/ActiveRecord def self.query(projects, public_only: true) + issues_filtered_by_type = Issue.opened.with_issue_type(Issue::TYPES_FOR_LIST) + if public_only - Issue.opened.public_only.where(project: projects) + issues_filtered_by_type.public_only.where(project: projects) else - Issue.opened.where(project: projects) + issues_filtered_by_type.where(project: projects) end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml index 4697e030b5c..6fc2f41b122 100644 --- a/app/views/projects/issues/_design_management.html.haml +++ b/app/views/projects/issues/_design_management.html.haml @@ -3,7 +3,7 @@ - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements') - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url } - link_end = '</a>'.html_safe -- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end } +- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS and have admin enable hashed storage. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end } - if @project.design_management_enabled? .js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } } diff --git a/app/views/projects/packages/packages/show.html.haml b/app/views/projects/packages/packages/show.html.haml index 9e68dbc4de1..97a3c6e7092 100644 --- a/app/views/projects/packages/packages/show.html.haml +++ b/app/views/projects/packages/packages/show.html.haml @@ -13,7 +13,7 @@ npm_help_path: help_page_path('user/packages/npm_registry/index'), maven_path: package_registry_project_url(@project.id, :maven), maven_help_path: help_page_path('user/packages/maven_repository/index'), - conan_path: package_registry_instance_url(:conan), + conan_path: package_registry_project_url(@project.id, :conan), conan_help_path: help_page_path('user/packages/conan_repository/index'), nuget_path: nuget_package_registry_url(@project.id), nuget_help_path: help_page_path('user/packages/nuget_repository/index'), diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 2425bcf61d9..647421a8fbe 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -8,6 +8,6 @@ = markdown integration.help .service-settings - - if @admin_integration - .js-vue-admin-integration-settings{ data: integration_form_data(@admin_integration) } + - if @default_integration + .js-vue-default-integration-settings{ data: integration_form_data(@default_integration) } .js-vue-integration-settings{ data: integration_form_data(integration) } diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 86c73e36317..d5b1245abb3 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -2,7 +2,6 @@ - group = local_assigns.fetch(:group, false) -# TODO: Move group_id and can_admin_list to the board store See: https://gitlab.com/gitlab-org/gitlab/-/issues/213082 -- group_id = @group&.id || "null" - can_admin_list = can?(current_user, :admin_list, current_board_parent) == true - @no_breadcrumb_container = true - @no_container = true @@ -24,11 +23,7 @@ "ref" => "board_content", ":lists" => "state.lists", ":can-admin-list" => can_admin_list, - ":group-id" => group_id, - ":disabled" => "disabled", - ":issue-link-base" => "issueLinkBase", - ":root-path" => "rootPath", - ":board-id" => "boardId" } + ":disabled" => "disabled" } - else .boards-list.w-100.py-3.px-2.text-nowrap{ data: { qa_selector: "boards_list" } } .boards-app-loading.w-100.text-center{ "v-if" => "loading" } @@ -37,12 +32,8 @@ "v-for" => "list in state.lists", "ref" => "board", ":can-admin-list" => can_admin_list, - ":group-id" => group_id, ":list" => "list", ":disabled" => "disabled", - ":issue-link-base" => "issueLinkBase", - ":root-path" => "rootPath", - ":board-id" => "boardId", ":key" => "list.id" } = render "shared/boards/components/sidebar", group: group = render_if_exists 'shared/boards/components/board_settings_sidebar' @@ -51,6 +42,4 @@ "milestone-path" => milestones_filter_dropdown_path, "label-path" => labels_filter_path_with_defaults, "empty-state-svg" => image_path('illustrations/issues.svg'), - ":issue-link-base" => "issueLinkBase", - ":root-path" => "rootPath", ":project-id" => @project.id } diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index 6e4feea1b26..b0307571448 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -37,10 +37,7 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker # Refresh the branch cache in case garbage collection caused a ref lookup to fail flush_ref_caches(project) if task == :gc - if task != :pack_refs - project.repository.expire_statistics_caches - Projects::UpdateStatisticsService.new(project, nil, statistics: [:repository_size, :lfs_objects_size]).execute - end + update_repository_statistics(project) if task != :pack_refs # In case pack files are deleted, release libgit2 cache and open file # descriptors ASAP instead of waiting for Ruby garbage collection @@ -106,6 +103,13 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker project.repository.has_visible_content? end + def update_repository_statistics(project) + project.repository.expire_statistics_caches + return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary + + Projects::UpdateStatisticsService.new(project, nil, statistics: [:repository_size, :lfs_objects_size]).execute + end + def bitmaps_enabled? Gitlab::CurrentSettings.housekeeping_bitmaps_enabled end diff --git a/changelogs/unreleased/246655-hashed-storage-trigger-migration-to-hashed-storage-via-post-deploy.yml b/changelogs/unreleased/246655-hashed-storage-trigger-migration-to-hashed-storage-via-post-deploy.yml new file mode 100644 index 00000000000..472dde6b9f0 --- /dev/null +++ b/changelogs/unreleased/246655-hashed-storage-trigger-migration-to-hashed-storage-via-post-deploy.yml @@ -0,0 +1,5 @@ +--- +title: 'Hashed Storage: forced automatic migration of legacy projects via background jobs' +merge_request: 42313 +author: +type: changed diff --git a/changelogs/unreleased/249910-does-not-update-repository-statistics-when-running-on-a-read-only-.yml b/changelogs/unreleased/249910-does-not-update-repository-statistics-when-running-on-a-read-only-.yml new file mode 100644 index 00000000000..187b6dddef0 --- /dev/null +++ b/changelogs/unreleased/249910-does-not-update-repository-statistics-when-running-on-a-read-only-.yml @@ -0,0 +1,6 @@ +--- +title: Does not update repository statistics when running housekeeping and repository + cleanup on a read-only instance +merge_request: 42409 +author: +type: fixed diff --git a/changelogs/unreleased/41818.yml b/changelogs/unreleased/41818.yml new file mode 100644 index 00000000000..cecfb223f8d --- /dev/null +++ b/changelogs/unreleased/41818.yml @@ -0,0 +1,5 @@ +--- +title: Improve design management not available message +merge_request: 41818 +author: Ben Bodenmiller @bbodenmiller +type: fixed diff --git a/changelogs/unreleased/add_deduplicated_column_to_security_findings.yml b/changelogs/unreleased/add_deduplicated_column_to_security_findings.yml new file mode 100644 index 00000000000..4740f9b6dbf --- /dev/null +++ b/changelogs/unreleased/add_deduplicated_column_to_security_findings.yml @@ -0,0 +1,6 @@ +--- +title: Add `deduplicated` column to `security_findings` table along with the compound + index on `scan_id` and `deduplicated` and remove the index on `scan_id` +merge_request: 42270 +author: +type: added diff --git a/changelogs/unreleased/improve-group-coverage-query-performance.yml b/changelogs/unreleased/improve-group-coverage-query-performance.yml new file mode 100644 index 00000000000..4fc099d9047 --- /dev/null +++ b/changelogs/unreleased/improve-group-coverage-query-performance.yml @@ -0,0 +1,5 @@ +--- +title: Preload projects to prevent N+1 when populating project name +merge_request: 40769 +author: +type: performance diff --git a/changelogs/unreleased/update-conan-installation-snippet.yml b/changelogs/unreleased/update-conan-installation-snippet.yml new file mode 100644 index 00000000000..09c92dfa44e --- /dev/null +++ b/changelogs/unreleased/update-conan-installation-snippet.yml @@ -0,0 +1,5 @@ +--- +title: Update conan remote instructions snippet to show project-level remote +merge_request: 42526 +author: +type: changed diff --git a/db/migrate/20200914155135_add_deduplicated_flag_into_security_findings_table.rb b/db/migrate/20200914155135_add_deduplicated_flag_into_security_findings_table.rb new file mode 100644 index 00000000000..f403c2d9a07 --- /dev/null +++ b/db/migrate/20200914155135_add_deduplicated_flag_into_security_findings_table.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddDeduplicatedFlagIntoSecurityFindingsTable < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :security_findings, :deduplicated, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20200914183227_add_index_on_deduplicated_column_of_security_findings.rb b/db/migrate/20200914183227_add_index_on_deduplicated_column_of_security_findings.rb new file mode 100644 index 00000000000..1d0f656df4d --- /dev/null +++ b/db/migrate/20200914183227_add_index_on_deduplicated_column_of_security_findings.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddIndexOnDeduplicatedColumnOfSecurityFindings < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_security_findings_on_scan_id_and_deduplicated' + + disable_ddl_transaction! + + def up + add_concurrent_index :security_findings, [:scan_id, :deduplicated], name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :security_findings, INDEX_NAME + end +end diff --git a/db/migrate/20200914184212_remove_index_on_security_findings_scan_id.rb b/db/migrate/20200914184212_remove_index_on_security_findings_scan_id.rb new file mode 100644 index 00000000000..342dba537d5 --- /dev/null +++ b/db/migrate/20200914184212_remove_index_on_security_findings_scan_id.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class RemoveIndexOnSecurityFindingsScanId < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_security_findings_on_scan_id' + + disable_ddl_transaction! + + def up + remove_concurrent_index_by_name :security_findings, INDEX_NAME + end + + def down + add_concurrent_index :security_findings, :scan_id, name: INDEX_NAME + end +end diff --git a/db/post_migrate/20200915044225_schedule_migration_to_hashed_storage.rb b/db/post_migrate/20200915044225_schedule_migration_to_hashed_storage.rb new file mode 100644 index 00000000000..553d060dc58 --- /dev/null +++ b/db/post_migrate/20200915044225_schedule_migration_to_hashed_storage.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ScheduleMigrationToHashedStorage < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + DOWNTIME = false + MIGRATION = 'MigrateToHashedStorage' + + disable_ddl_transaction! + + def up + migrate_async(MIGRATION) + end + + def down + # NO-OP + end +end diff --git a/db/schema_migrations/20200914155135 b/db/schema_migrations/20200914155135 new file mode 100644 index 00000000000..17cd6c67033 --- /dev/null +++ b/db/schema_migrations/20200914155135 @@ -0,0 +1 @@ +0b01a251eb736eb9e9986214c69ea5f4a11d8293bc4083af1585ce265c8f69de
\ No newline at end of file diff --git a/db/schema_migrations/20200914183227 b/db/schema_migrations/20200914183227 new file mode 100644 index 00000000000..3fb2d461452 --- /dev/null +++ b/db/schema_migrations/20200914183227 @@ -0,0 +1 @@ +570a7de82da3dabfd12f3099cdc028128d4b61a4ecf6edd4b3102031d914316c
\ No newline at end of file diff --git a/db/schema_migrations/20200914184212 b/db/schema_migrations/20200914184212 new file mode 100644 index 00000000000..1053454b960 --- /dev/null +++ b/db/schema_migrations/20200914184212 @@ -0,0 +1 @@ +dcfd1f006aa6695e0fd8f392c7288f2d80bddfe53827d3a2f079bd039f4fe0de
\ No newline at end of file diff --git a/db/schema_migrations/20200915044225 b/db/schema_migrations/20200915044225 new file mode 100644 index 00000000000..440fddf4080 --- /dev/null +++ b/db/schema_migrations/20200915044225 @@ -0,0 +1 @@ +0c659c7cbbda4d2fbbbd344f0fca50860ccf54ec8b666772d53a4c6fa602d097
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 1bf6b05da5e..899c5de8ae6 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -15495,6 +15495,7 @@ CREATE TABLE public.security_findings ( severity smallint NOT NULL, confidence smallint NOT NULL, project_fingerprint text NOT NULL, + deduplicated boolean DEFAULT false NOT NULL, CONSTRAINT check_b9508c6df8 CHECK ((char_length(project_fingerprint) <= 40)) ); @@ -21016,7 +21017,7 @@ CREATE INDEX index_security_findings_on_confidence ON public.security_findings U CREATE INDEX index_security_findings_on_project_fingerprint ON public.security_findings USING btree (project_fingerprint); -CREATE INDEX index_security_findings_on_scan_id ON public.security_findings USING btree (scan_id); +CREATE INDEX index_security_findings_on_scan_id_and_deduplicated ON public.security_findings USING btree (scan_id, deduplicated); CREATE INDEX index_security_findings_on_scanner_id ON public.security_findings USING btree (scanner_id); diff --git a/doc/ci/parent_child_pipelines.md b/doc/ci/parent_child_pipelines.md index 3e3af9427c3..45b472bfb91 100644 --- a/doc/ci/parent_child_pipelines.md +++ b/doc/ci/parent_child_pipelines.md @@ -150,12 +150,36 @@ We also have an [example project using Dynamic Child Pipelines with Jsonnet](htt In GitLab 12.9, the child pipeline could fail to be created in certain cases, causing the parent pipeline to fail. This is [resolved in GitLab 12.10](https://gitlab.com/gitlab-org/gitlab/-/issues/209070). -## Limitations +## Nested child pipelines -In GitLab 13.3 and older, a parent pipeline can trigger many child pipelines, but -those child pipeline cannot trigger further child pipelines. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/29651) in GitLab 13.4. +> - It's [deployed behind a feature flag](../user/feature_flags.md), disabled by default. +> - It's disabled on GitLab.com. +> - It's not recommended for production use. +> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-nested-child-pipelines). **(CORE ONLY)** -In GitLab 13.4 and newer, the [maximum depth of child pipelines was increased to 2](https://gitlab.com/gitlab-org/gitlab/-/issues/29651). -A parent pipeline can trigger many child pipelines. These child pipelines can trigger -their own child pipelines. This second layer of child pipelines cannot trigger further -child pipelines. +Parent and child pipelines were introduced with a maximum depth of one level of child +pipelines, which was later increased to two. A parent pipeline can trigger many child +pipelines, and these child pipelines can trigger their own child pipelines. It's not +possible to trigger another level of child pipelines. + +### Enable or disable nested child pipelines **(CORE ONLY)** + +Nested child pipelines with a depth of two are under development and not ready for +production use. This feature is deployed behind a feature flag that is **disabled by default**. +Enabling this feature allows child pipelines to trigger one more level of child pipelines. +The second level of child pipelines cannot trigger any further child pipelines. +[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md) +can enable it. + +To enable it: + +```ruby +Feature.enable(:ci_child_of_child_pipeline) +``` + +To disable it: + +```ruby +Feature.disable(:ci_child_of_child_pipeline) +``` diff --git a/doc/install/azure/index.md b/doc/install/azure/index.md index 00c868c6c41..b6e3025a0e0 100644 --- a/doc/install/azure/index.md +++ b/doc/install/azure/index.md @@ -1,15 +1,23 @@ --- +stage: Enablement +group: Distribution +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers description: 'Learn how to spin up a pre-configured GitLab VM on Microsoft Azure.' type: howto --- # Install GitLab on Microsoft Azure -Azure is Microsoft's business cloud and GitLab is a pre-configured offering on the Azure Marketplace. -Hopefully, you aren't surprised to hear that Microsoft and Azure have embraced open source software -like Ubuntu, Red Hat Enterprise Linux, and of course - GitLab! This means that you can spin up a -pre-configured GitLab VM and have your very own private GitLab up and running in around 30 minutes. -Let's get started. +CAUTION: **Deprecated:** +The GitLab image in the Azure Marketplace is deprecated. You can track GitLab's +efforts to [post a new image](https://gitlab.com/gitlab-com/alliances/microsoft/gitlab-tracker/-/issues/2). + +Azure is Microsoft's business cloud and GitLab is a pre-configured offering on +the Azure Marketplace. Hopefully, you aren't surprised to hear that Microsoft +and Azure have embraced open source software like Ubuntu, Red Hat Enterprise Linux, +and of course - GitLab! This means that you can spin up a pre-configured +GitLab VM and have your very own private GitLab up and running in around 30 +minutes. Let's get started. ## Getting started diff --git a/lib/gitlab/background_migration/migrate_to_hashed_storage.rb b/lib/gitlab/background_migration/migrate_to_hashed_storage.rb new file mode 100644 index 00000000000..4054db4fb87 --- /dev/null +++ b/lib/gitlab/background_migration/migrate_to_hashed_storage.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # Background migration to move any legacy project to Hashed Storage + class MigrateToHashedStorage + def perform + batch_size = helper.batch_size + legacy_projects_count = Project.with_unmigrated_storage.count + + if storage_migrator.rollback_pending? + logger.warn( + migrator: 'MigrateToHashedStorage', + message: 'Aborting an storage rollback operation currently in progress' + ) + + storage_migrator.abort_rollback! + end + + if legacy_projects_count == 0 + logger.info( + migrator: 'MigrateToHashedStorage', + message: 'There are no projects requiring migration to Hashed Storage' + ) + + return + end + + logger.info( + migrator: 'MigrateToHashedStorage', + message: "Enqueuing migration of #{legacy_projects_count} projects in batches of #{batch_size}" + ) + + helper.project_id_batches_migration do |start, finish| + storage_migrator.bulk_schedule_migration(start: start, finish: finish) + + logger.info( + migrator: 'MigrateToHashedStorage', + message: "Enqueuing migration of projects in batches of #{batch_size} from ID=#{start} to ID=#{finish}", + batch_from: start, + batch_to: finish + ) + end + end + + private + + def helper + Gitlab::HashedStorage::RakeHelper + end + + def storage_migrator + @storage_migrator ||= Gitlab::HashedStorage::Migrator.new + end + + def logger + @logger ||= ::Gitlab::BackgroundMigration::Logger.build + end + end + end +end diff --git a/lib/gitlab/hashed_storage/migrator.rb b/lib/gitlab/hashed_storage/migrator.rb index d30e5587a64..b57560544c8 100644 --- a/lib/gitlab/hashed_storage/migrator.rb +++ b/lib/gitlab/hashed_storage/migrator.rb @@ -93,6 +93,14 @@ module Gitlab any_non_empty_queue?(::HashedStorage::RollbackerWorker, ::HashedStorage::ProjectRollbackWorker) end + # Remove all remaining scheduled rollback operations + # + def abort_rollback! + [::HashedStorage::RollbackerWorker, ::HashedStorage::ProjectRollbackWorker].each do |worker| + Sidekiq::Queue.new(worker.queue).clear + end + end + private def any_non_empty_queue?(*workers) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a59786d97ca..0cbdd2ebf67 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8800,7 +8800,7 @@ msgstr "" msgid "DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again." msgstr "" -msgid "DesignManagement|To upload designs, you'll need to enable LFS. %{requirements_link_start}More information%{requirements_link_end}" +msgid "DesignManagement|To upload designs, you'll need to enable LFS and have admin enable hashed storage. %{requirements_link_start}More information%{requirements_link_end}" msgstr "" msgid "DesignManagement|Unresolve thread" @@ -16843,6 +16843,9 @@ msgstr "" msgid "New Snippet" msgstr "" +msgid "New Test Case" +msgstr "" + msgid "New User" msgstr "" @@ -23622,6 +23625,9 @@ msgstr "" msgid "Something went wrong while creating a requirement." msgstr "" +msgid "Something went wrong while creating a test case." +msgstr "" + msgid "Something went wrong while deleting description changes. Please try again." msgstr "" @@ -24360,6 +24366,9 @@ msgstr "" msgid "Submit search" msgstr "" +msgid "Submit test case" +msgstr "" + msgid "Submit the current review." msgstr "" @@ -26696,6 +26705,9 @@ msgstr "" msgid "Too many projects enabled. You will need to manage them via the console or the API." msgstr "" +msgid "Too much data" +msgstr "" + msgid "Topics (optional)" msgstr "" @@ -27872,6 +27884,9 @@ msgstr "" msgid "Value Stream Name" msgstr "" +msgid "ValueStreamAnalyticsStage|We don't have enough data to show this stage." +msgstr "" + msgid "ValueStreamAnalytics|%{days}d" msgstr "" diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb index 90b3dc54064..826acaa2e0a 100644 --- a/qa/qa/page/project/issue/show.rb +++ b/qa/qa/page/project/issue/show.rb @@ -70,7 +70,10 @@ module QA end def click_remove_related_issue_button - click_element(:remove_related_issue_button) + retry_until(sleep_interval: 5) do + click_element(:remove_related_issue_button) + has_no_element?(:remove_related_issue_button, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) + end end def click_close_issue_button diff --git a/qa/qa/specs/features/browser_ui/2_plan/related_issues/related_issues_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/related_issues/related_issues_spec.rb index c9cb8d4aba6..a2190a8cf41 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/related_issues/related_issues_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/related_issues/related_issues_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - RSpec.describe 'Plan', :reliable do + RSpec.describe 'Plan' do describe 'Related issues' do let(:project) do Resource::Project.fabricate_via_api! do |project| @@ -30,19 +30,14 @@ module QA Page::Project::Issue::Show.perform do |show| max_wait = 60 - wait_interval = 1 show.relate_issue(issue_2) - show.wait_until(reload: false, max_duration: max_wait, sleep_interval: wait_interval) do - expect(show.related_issuable_item).to have_content(issue_2.title) - end + expect(show.related_issuable_item).to have_text(issue_2.title, wait: max_wait) show.click_remove_related_issue_button - show.wait_until(reload: false, max_duration: max_wait, sleep_interval: wait_interval) do - expect(show).not_to have_content(issue_2.title) - end + expect(show).to have_no_text(issue_2.title, wait: max_wait) end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 0be90a09565..ef72416fc71 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -82,6 +82,16 @@ RSpec.describe Projects::IssuesController do expect(response).to have_gitlab_http_status(:ok) end + it 'returns only list type issues' do + issue = create(:issue, project: project) + incident = create(:issue, project: project, issue_type: 'incident') + create(:issue, project: project, issue_type: 'test_case') + + get :index, params: { namespace_id: project.namespace, project_id: project } + + expect(assigns(:issues)).to contain_exactly(issue, incident) + end + it "returns 301 if request path doesn't match project path" do get :index, params: { namespace_id: project.namespace, project_id: project.path.upcase } diff --git a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb index 8998cae621d..de1fcc9d787 100644 --- a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb +++ b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb @@ -38,7 +38,7 @@ RSpec.describe 'User uploads new design', :js do let(:feature_enabled) { false } it 'shows the message about requirements' do - expect(page).to have_content("To upload designs, you'll need to enable LFS.") + expect(page).to have_content("To upload designs, you'll need to enable LFS and have admin enable hashed storage.") end end diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index b51a82f2a35..80d7a72151d 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -52,10 +52,12 @@ export default function createComponent({ list, issues: list.issues, loading: false, - issueLinkBase: '/issues', - rootPath: '/', ...componentProps, }, + provide: { + groupId: null, + rootPath: '/', + }, }).$mount(); Vue.nextTick(() => { diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 3a64b004847..88883ae61d4 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -45,10 +45,12 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP list, issues: list.issues, loading: false, - issueLinkBase: '/issues', - rootPath: '/', ...componentProps, }, + provide: { + groupId: null, + rootPath: '/', + }, }).$mount(); Vue.nextTick(() => { diff --git a/spec/frontend/boards/board_new_issue_spec.js b/spec/frontend/boards/board_new_issue_spec.js index 94afc8a2b45..3eebfeca965 100644 --- a/spec/frontend/boards/board_new_issue_spec.js +++ b/spec/frontend/boards/board_new_issue_spec.js @@ -1,6 +1,7 @@ /* global List */ import Vue from 'vue'; +import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import boardNewIssue from '~/boards/components/board_new_issue.vue'; @@ -10,6 +11,7 @@ import '~/boards/models/list'; import { listObj, boardsMockInterceptor } from './mock_data'; describe('Issue boards new issue form', () => { + let wrapper; let vm; let list; let mock; @@ -24,13 +26,11 @@ describe('Issue boards new issue form', () => { const dummySubmitEvent = { preventDefault() {}, }; - vm.$refs.submitButton = vm.$el.querySelector('.btn-success'); - return vm.submit(dummySubmitEvent); + wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' }); + return wrapper.vm.submit(dummySubmitEvent); }; beforeEach(() => { - setFixtures('<div class="test-container"></div>'); - const BoardNewIssueComp = Vue.extend(boardNewIssue); mock = new MockAdapter(axios); @@ -43,46 +43,52 @@ describe('Issue boards new issue form', () => { newIssueMock = Promise.resolve(promiseReturn); jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock); - vm = new BoardNewIssueComp({ + wrapper = mount(BoardNewIssueComp, { propsData: { + disabled: false, list, }, - }).$mount(document.querySelector('.test-container')); + provide: { + groupId: null, + }, + }); + + vm = wrapper.vm; return Vue.nextTick(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); mock.restore(); }); it('calls submit if submit button is clicked', () => { - jest.spyOn(vm, 'submit').mockImplementation(e => e.preventDefault()); + jest.spyOn(wrapper.vm, 'submit').mockImplementation(); vm.title = 'Testing Title'; - return Vue.nextTick().then(() => { - vm.$el.querySelector('.btn-success').click(); - - expect(vm.submit.mock.calls.length).toBe(1); - }); + return Vue.nextTick() + .then(submitIssue) + .then(() => { + expect(wrapper.vm.submit).toHaveBeenCalled(); + }); }); it('disables submit button if title is empty', () => { - expect(vm.$el.querySelector('.btn-success').disabled).toBe(true); + expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true); }); it('enables submit button if title is not empty', () => { - vm.title = 'Testing Title'; + wrapper.setData({ title: 'Testing Title' }); return Vue.nextTick().then(() => { - expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title'); - expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true); + expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title'); + expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false); }); }); it('clears title after clicking cancel', () => { - vm.$el.querySelector('.btn-default').click(); + wrapper.find({ ref: 'cancelButton' }).trigger('click'); return Vue.nextTick().then(() => { expect(vm.title).toBe(''); @@ -97,7 +103,7 @@ describe('Issue boards new issue form', () => { describe('submit success', () => { it('creates new issue', () => { - vm.title = 'submit title'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -107,17 +113,18 @@ describe('Issue boards new issue form', () => { }); it('enables button after submit', () => { - vm.title = 'submit issue'; + jest.spyOn(wrapper.vm, 'submit').mockImplementation(); + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) .then(() => { - expect(vm.$el.querySelector('.btn-success').disabled).toBe(false); + expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false); }); }); it('clears title after submit', () => { - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -128,7 +135,7 @@ describe('Issue boards new issue form', () => { it('sets detail issue after submit', () => { expect(boardsStore.detail.issue.title).toBe(undefined); - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -138,7 +145,7 @@ describe('Issue boards new issue form', () => { }); it('sets detail list after submit', () => { - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -149,7 +156,7 @@ describe('Issue boards new issue form', () => { it('sets detail weight after submit', () => { boardsStore.weightFeatureAvailable = true; - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -160,7 +167,7 @@ describe('Issue boards new issue form', () => { it('does not set detail weight after submit', () => { boardsStore.weightFeatureAvailable = false; - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js index 2926f7d7e57..80f649a1a96 100644 --- a/spec/frontend/boards/components/board_card_layout_spec.js +++ b/spec/frontend/boards/components/board_card_layout_spec.js @@ -31,12 +31,14 @@ describe('Board card layout', () => { propsData: { list, issue: list.issues[0], - issueLinkBase: '/', disabled: false, index: 0, - rootPath: '/', ...propsData, }, + provide: { + groupId: null, + rootPath: '/', + }, }); }; diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 1615a9d4df2..a3ddcdf01b7 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -38,12 +38,14 @@ describe('BoardCard', () => { propsData: { list, issue: list.issues[0], - issueLinkBase: '/', disabled: false, index: 0, - rootPath: '/', ...propsData, }, + provide: { + groupId: null, + rootPath: '/', + }, }); }; diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index c06b7aceaad..2a4dbbb989e 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -59,10 +59,11 @@ describe('Board Column Component', () => { propsData: { boardId, disabled: false, - issueLinkBase: '/', - rootPath: '/', list, }, + provide: { + boardId, + }, }); }; diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 2bf16b6b950..df117d06cdf 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -39,11 +39,7 @@ describe('BoardContent', () => { propsData: { lists: mockListsWithModel, canAdminList: true, - groupId: 1, disabled: false, - issueLinkBase: '/', - rootPath: '/', - boardId: '1', }, store, }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 85666d47cb0..2439c347bf0 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -57,12 +57,12 @@ describe('Board List Header Component', () => { wrapper = shallowMount(BoardListHeader, { propsData: { - boardId, disabled: false, - issueLinkBase: '/', - rootPath: '/', list, }, + provide: { + boardId, + }, }); }; diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js index dee8cb7b6e5..7e22e9647f0 100644 --- a/spec/frontend/boards/issue_card_spec.js +++ b/spec/frontend/boards/issue_card_spec.js @@ -47,13 +47,15 @@ describe('Issue card component', () => { propsData: { list, issue, - issueLinkBase: '/test', - rootPath: '/', }, store, stubs: { GlLabel: true, }, + provide: { + groupId: null, + rootPath: '/', + }, }); }); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 581120344de..6c40b1ba3a7 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -119,7 +119,7 @@ describe('GfmAutoComplete', () => { const defaultMatcher = (context, flag, subtext) => gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext); - const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$']; + const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$', '+']; const otherFlags = ['/', ':']; const flags = flagsUseDefaultMatcher.concat(otherFlags); @@ -153,7 +153,6 @@ describe('GfmAutoComplete', () => { 'я', '.', "'", - '+', '-', '_', ]; diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js index fed61233e55..bb8165d1a52 100644 --- a/spec/frontend/ide/components/ide_status_list_spec.js +++ b/spec/frontend/ide/components/ide_status_list_spec.js @@ -75,7 +75,8 @@ describe('ide/components/ide_status_list', () => { describe('with binary file', () => { beforeEach(() => { - activeFile.binary = true; + activeFile.name = 'abc.dat'; + activeFile.content = '🐱'; // non-ascii binary content createComponent(); }); diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index ad27954cd10..ae497106f73 100644 --- a/spec/frontend/ide/components/new_dropdown/upload_spec.js +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -85,7 +85,6 @@ describe('new dropdown upload', () => { name: textFile.name, type: 'blob', content: 'plain text', - binary: false, rawPath: '', }); }) @@ -102,7 +101,6 @@ describe('new dropdown upload', () => { name: binaryFile.name, type: 'blob', content: binaryTarget.result.split('base64,')[1], - binary: true, rawPath: binaryTarget.result, }); }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index f0ae2ba732b..9f4c9c1622a 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -45,7 +45,7 @@ describe('RepoEditor', () => { const createOpenFile = path => { const origFile = store.state.openFiles[0]; - const newFile = { ...origFile, path, key: path }; + const newFile = { ...origFile, path, key: path, name: 'myfile.txt', content: 'hello world' }; store.state.entries[path] = newFile; @@ -54,8 +54,9 @@ describe('RepoEditor', () => { beforeEach(() => { const f = { - ...file(), + ...file('file.txt'), viewMode: FILE_VIEW_MODE_EDITOR, + content: 'hello world', }; const storeOptions = createStoreOptions(); @@ -142,6 +143,7 @@ describe('RepoEditor', () => { ...vm.file, projectId: 'namespace/project', path: 'sample.md', + name: 'sample.md', content: 'testing 123', }); @@ -200,7 +202,8 @@ describe('RepoEditor', () => { describe('when open file is binary and not raw', () => { beforeEach(done => { - vm.file.binary = true; + vm.file.name = 'file.dat'; + vm.file.content = '🐱'; // non-ascii binary content vm.$nextTick(done); }); @@ -407,6 +410,9 @@ describe('RepoEditor', () => { beforeEach(done => { jest.spyOn(vm.editor, 'updateDimensions').mockImplementation(); vm.file.viewMode = FILE_VIEW_MODE_PREVIEW; + vm.file.name = 'myfile.md'; + vm.file.content = 'hello world'; + vm.$nextTick(done); }); @@ -650,7 +656,6 @@ describe('RepoEditor', () => { path: 'foo/foo.png', type: 'blob', content: 'Zm9v', - binary: true, rawPath: 'data:image/png;base64,Zm9v', }); }); diff --git a/spec/frontend/ide/lib/files_spec.js b/spec/frontend/ide/lib/files_spec.js index b223cf64278..8ca6f01d9a6 100644 --- a/spec/frontend/ide/lib/files_spec.js +++ b/spec/frontend/ide/lib/files_spec.js @@ -1,11 +1,9 @@ -import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import { decorateFiles, splitParent } from '~/ide/lib/files'; import { decorateData } from '~/ide/stores/utils'; const createEntries = paths => { const createEntry = (acc, { path, type, children }) => { const { name, parent } = splitParent(path); - const previewMode = viewerInformationForPath(name); acc[path] = { ...decorateData({ @@ -13,8 +11,6 @@ const createEntries = paths => { name, path, type, - previewMode, - binary: (previewMode && previewMode.binary) || false, parentPath: parent, }), tree: children.map(childName => expect.objectContaining({ name: childName })), diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index 5166e07abcb..974c0715c06 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -241,7 +241,6 @@ describe('IDE store file actions', () => { 200, { raw_path: 'raw_path', - binary: false, }, { 'page-title': 'testing getFileData', @@ -305,7 +304,6 @@ describe('IDE store file actions', () => { 200, { raw_path: 'raw_path', - binary: false, }, { 'page-title': 'testing old-dull-file', diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js index ff904bbc9cd..b53e40be980 100644 --- a/spec/frontend/ide/stores/mutations/file_spec.js +++ b/spec/frontend/ide/stores/mutations/file_spec.js @@ -61,13 +61,11 @@ describe('IDE store file mutations', () => { mutations.SET_FILE_DATA(localState, { data: { raw_path: 'raw', - binary: true, }, file: localFile, }); expect(localFile.rawPath).toBe('raw'); - expect(localFile.binary).toBeTruthy(); expect(localFile.raw).toBeNull(); expect(localFile.baseRaw).toBeNull(); }); diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index 4a1e1f3c45e..97dc8217ecc 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -13,60 +13,78 @@ import { describe('WebIDE utils', () => { describe('isTextFile', () => { - it('returns false for known binary types', () => { - expect(isTextFile('file content', 'image/png', 'my.png')).toBeFalsy(); - // mime types are case insensitive - expect(isTextFile('file content', 'IMAGE/PNG', 'my.png')).toBeFalsy(); + it.each` + mimeType | name | type | result + ${'image/png'} | ${'my.png'} | ${'binary'} | ${false} + ${'IMAGE/PNG'} | ${'my.png'} | ${'binary'} | ${false} + ${'text/plain'} | ${'my.txt'} | ${'text'} | ${true} + ${'TEXT/PLAIN'} | ${'my.txt'} | ${'text'} | ${true} + `('returns $result for known $type types', ({ mimeType, name, result }) => { + expect(isTextFile({ content: 'file content', mimeType, name })).toBe(result); }); - it('returns true for known text types', () => { - expect(isTextFile('file content', 'text/plain', 'my.txt')).toBeTruthy(); - // mime types are case insensitive - expect(isTextFile('file content', 'TEXT/PLAIN', 'my.txt')).toBeTruthy(); - }); + it.each` + content | mimeType | name + ${'{"éêė":"value"}'} | ${'application/json'} | ${'my.json'} + ${'{"éêė":"value"}'} | ${'application/json'} | ${'.tsconfig'} + ${'SELECT "éêė" from tablename'} | ${'application/sql'} | ${'my.sql'} + ${'{"éêė":"value"}'} | ${'application/json'} | ${'MY.JSON'} + ${'SELECT "éêė" from tablename'} | ${'application/sql'} | ${'MY.SQL'} + ${'var code = "something"'} | ${'application/javascript'} | ${'Gruntfile'} + ${'MAINTAINER Александр "a21283@me.com"'} | ${'application/octet-stream'} | ${'dockerfile'} + `( + 'returns true for file extensions that Monaco supports syntax highlighting for', + ({ content, mimeType, name }) => { + expect(isTextFile({ content, mimeType, name })).toBe(true); + }, + ); - it('returns true for file extensions that Monaco supports syntax highlighting for', () => { - // test based on both MIME and extension - expect(isTextFile('{"éêė":"value"}', 'application/json', 'my.json')).toBeTruthy(); - expect(isTextFile('{"éêė":"value"}', 'application/json', '.tsconfig')).toBeTruthy(); - expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'my.sql')).toBeTruthy(); + it('returns false if filename is same as the expected extension', () => { + expect( + isTextFile({ + name: 'sql', + content: 'SELECT "éêė" from tablename', + mimeType: 'application/sql', + }), + ).toBeFalsy(); }); - it('returns true even irrespective of whether the mimes, extensions or file names are lowercase or upper case', () => { - expect(isTextFile('{"éêė":"value"}', 'application/json', 'MY.JSON')).toBeTruthy(); - expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'MY.SQL')).toBeTruthy(); - expect( - isTextFile('var code = "something"', 'application/javascript', 'Gruntfile'), - ).toBeTruthy(); + it('returns true for ASCII only content for unknown types', () => { expect( - isTextFile( - 'MAINTAINER Александр "alexander11354322283@me.com"', - 'application/octet-stream', - 'dockerfile', - ), + isTextFile({ + name: 'hello.mytype', + content: 'plain text', + mimeType: 'application/x-new-type', + }), ).toBeTruthy(); }); - it('returns false if filename is same as the expected extension', () => { - expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'sql')).toBeFalsy(); - }); - - it('returns true for ASCII only content for unknown types', () => { - expect(isTextFile('plain text', 'application/x-new-type', 'hello.mytype')).toBeTruthy(); + it('returns false for non-ASCII content for unknown types', () => { + expect( + isTextFile({ + name: 'my.random', + content: '{"éêė":"value"}', + mimeType: 'application/octet-stream', + }), + ).toBeFalsy(); }); - it('returns true for relevant filenames', () => { - expect( - isTextFile( - 'MAINTAINER Александр "alexander11354322283@me.com"', - 'application/octet-stream', - 'Dockerfile', - ), - ).toBeTruthy(); + it.each` + name | result + ${'myfile.txt'} | ${true} + ${'Dockerfile'} | ${true} + ${'img.png'} | ${false} + ${'abc.js'} | ${true} + ${'abc.random'} | ${false} + ${'image.jpeg'} | ${false} + `('returns $result for $filename when no content or mimeType is passed', ({ name, result }) => { + expect(isTextFile({ name })).toBe(result); }); - it('returns false for non-ASCII content for unknown types', () => { - expect(isTextFile('{"éêė":"value"}', 'application/octet-stream', 'my.random')).toBeFalsy(); + it('returns true if content is empty string but false if content is not passed', () => { + expect(isTextFile({ name: 'abc.dat' })).toBe(false); + expect(isTextFile({ name: 'abc.dat', content: '' })).toBe(true); + expect(isTextFile({ name: 'abc.dat', content: ' ' })).toBe(true); }); }); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index fccb56f4597..eeb5d21d62c 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -137,13 +137,13 @@ describe('IntegrationForm', () => { }); }); - describe('adminState state is null', () => { + describe('defaultState state is null', () => { it('does not render OverrideDropdown', () => { createComponent( {}, {}, { - adminState: null, + defaultState: null, }, ); @@ -151,13 +151,13 @@ describe('IntegrationForm', () => { }); }); - describe('adminState state is an object', () => { + describe('defaultState state is an object', () => { it('renders OverrideDropdown', () => { createComponent( {}, {}, { - adminState: { + defaultState: { ...mockIntegrationProps, }, }, diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js index 44e14cb3760..f312c456d5f 100644 --- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js +++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js @@ -13,15 +13,15 @@ describe('OverrideDropdown', () => { override: true, }; - const defaultAdminStateProps = { + const defaultDefaultStateProps = { integrationLevel: 'group', }; - const createComponent = (props = {}, adminStateProps = {}) => { + const createComponent = (props = {}, defaultStateProps = {}) => { wrapper = shallowMount(OverrideDropdown, { propsData: { ...defaultProps, ...props }, store: createStore({ - adminState: { ...defaultAdminStateProps, ...adminStateProps }, + defaultState: { ...defaultDefaultStateProps, ...defaultStateProps }, }), }); }; diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js index 700d36edaad..3353e0c84cc 100644 --- a/spec/frontend/integrations/edit/store/getters_spec.js +++ b/spec/frontend/integrations/edit/store/getters_spec.js @@ -5,22 +5,22 @@ import { mockIntegrationProps } from '../mock_data'; describe('Integration form store getters', () => { let state; const customState = { ...mockIntegrationProps, type: 'CustomState' }; - const adminState = { ...mockIntegrationProps, type: 'AdminState' }; + const defaultState = { ...mockIntegrationProps, type: 'DefaultState' }; beforeEach(() => { state = createState({ customState }); }); describe('isInheriting', () => { - describe('when adminState is null', () => { + describe('when defaultState is null', () => { it('returns false', () => { expect(isInheriting(state)).toBe(false); }); }); - describe('when adminState is an object', () => { + describe('when defaultState is an object', () => { beforeEach(() => { - state.adminState = adminState; + state.defaultState = defaultState; }); describe('when override is false', () => { @@ -47,11 +47,11 @@ describe('Integration form store getters', () => { describe('propsSource', () => { beforeEach(() => { - state.adminState = adminState; + state.defaultState = defaultState; }); - it('equals adminState if inheriting', () => { - expect(propsSource(state, { isInheriting: true })).toEqual(adminState); + it('equals defaultState if inheriting', () => { + expect(propsSource(state, { isInheriting: true })).toEqual(defaultState); }); it('equals customState if not inheriting', () => { diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js index 2518e5c7b93..fc193850a94 100644 --- a/spec/frontend/integrations/edit/store/state_spec.js +++ b/spec/frontend/integrations/edit/store/state_spec.js @@ -3,7 +3,7 @@ import createState from '~/integrations/edit/store/state'; describe('Integration form state factory', () => { it('states default to null', () => { expect(createState()).toEqual({ - adminState: null, + defaultState: null, customState: {}, isSaving: false, isTesting: false, @@ -19,9 +19,9 @@ describe('Integration form state factory', () => { [null, { inheritFromId: null }, false], [null, { inheritFromId: 25 }, false], ])( - 'for adminState: %p, customState: %p: override = `%p`', - (adminState, customState, expected) => { - expect(createState({ adminState, customState }).override).toEqual(expected); + 'for defaultState: %p, customState: %p: override = `%p`', + (defaultState, customState, expected) => { + expect(createState({ defaultState, customState }).override).toEqual(expected); }, ); }); diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/issuable_create/components/issuable_form_spec.js index 0d922727209..e2c6b4d9521 100644 --- a/spec/frontend/issuable_create/components/issuable_form_spec.js +++ b/spec/frontend/issuable_create/components/issuable_form_spec.js @@ -65,6 +65,7 @@ describe('IssuableForm', () => { expect(titleFieldEl.find('label').text()).toBe('Title'); expect(titleFieldEl.find(GlFormInput).exists()).toBe(true); expect(titleFieldEl.find(GlFormInput).attributes('placeholder')).toBe('Title'); + expect(titleFieldEl.find(GlFormInput).attributes('autofocus')).toBe('true'); }); it('renders issuable description input field', () => { diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js new file mode 100644 index 00000000000..a96a4e15e6c --- /dev/null +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -0,0 +1,185 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlLabel } from '@gitlab/ui'; + +import IssuableItem from '~/issuable_list/components/issuable_item.vue'; + +import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data'; + +const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable } = {}) => + shallowMount(IssuableItem, { + propsData: { + issuableSymbol, + issuable, + }, + }); + +describe('IssuableItem', () => { + const mockLabels = mockIssuable.labels.nodes; + const mockAuthor = mockIssuable.author; + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('author', () => { + it('returns `issuable.author` reference', () => { + expect(wrapper.vm.author).toEqual(mockIssuable.author); + }); + }); + + describe('authorId', () => { + it.each` + authorId | returnValue + ${1} | ${1} + ${'1'} | ${1} + ${'gid://gitlab/User/1'} | ${'1'} + ${'foo'} | ${''} + `( + 'returns $returnValue when value of `issuable.author.id` is $authorId', + async ({ authorId, returnValue }) => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + author: { + ...mockAuthor, + id: authorId, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.authorId).toBe(returnValue); + }, + ); + }); + + describe('labels', () => { + it('returns `issuable.labels.nodes` reference when it is available', () => { + expect(wrapper.vm.labels).toEqual(mockLabels); + }); + + it('returns `issuable.labels` reference when it is available', async () => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + labels: mockLabels, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.labels).toEqual(mockLabels); + }); + + it('returns empty array when none of `issuable.labels.nodes` or `issuable.labels` are available', async () => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + labels: null, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.labels).toEqual([]); + }); + }); + + describe('createdAt', () => { + it('returns string containing timeago string based on `issuable.createdAt`', () => { + expect(wrapper.vm.createdAt).toContain('created'); + expect(wrapper.vm.createdAt).toContain('ago'); + }); + }); + + describe('updatedAt', () => { + it('returns string containing timeago string based on `issuable.updatedAt`', () => { + expect(wrapper.vm.updatedAt).toContain('updated'); + expect(wrapper.vm.updatedAt).toContain('ago'); + }); + }); + }); + + describe('methods', () => { + describe('scopedLabel', () => { + it.each` + label | labelType | returnValue + ${mockRegularLabel} | ${'regular'} | ${false} + ${mockScopedLabel} | ${'scoped'} | ${true} + `( + 'return $returnValue when provided label param is a $labelType label', + ({ label, returnValue }) => { + expect(wrapper.vm.scopedLabel(label)).toBe(returnValue); + }, + ); + }); + }); + + describe('template', () => { + it('renders issuable title', () => { + const titleEl = wrapper.find('[data-testid="issuable-title"]'); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.find(GlLink).attributes('href')).toBe(mockIssuable.webUrl); + expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title); + }); + + it('renders issuable reference', () => { + const referenceEl = wrapper.find('[data-testid="issuable-reference"]'); + + expect(referenceEl.exists()).toBe(true); + expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`); + }); + + it('renders issuable createdAt info', () => { + const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]'); + + expect(createdAtEl.exists()).toBe(true); + expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm GMT+0000'); + expect(createdAtEl.text()).toBe(wrapper.vm.createdAt); + }); + + it('renders issuable author info', () => { + const authorEl = wrapper.find('[data-testid="issuable-author"]'); + + expect(authorEl.exists()).toBe(true); + expect(authorEl.attributes()).toMatchObject({ + 'data-user-id': wrapper.vm.authorId, + 'data-username': mockAuthor.username, + 'data-name': mockAuthor.name, + 'data-avatar-url': mockAuthor.avatarUrl, + href: mockAuthor.webUrl, + }); + expect(authorEl.text()).toBe(mockAuthor.name); + }); + + it('renders gl-label component for each label present within `issuable` prop', () => { + const labelsEl = wrapper.findAll(GlLabel); + + expect(labelsEl.exists()).toBe(true); + expect(labelsEl).toHaveLength(mockLabels.length); + expect(labelsEl.at(0).props()).toMatchObject({ + backgroundColor: mockLabels[0].color, + title: mockLabels[0].title, + description: mockLabels[0].description, + scoped: false, + size: 'sm', + }); + }); + + it('renders issuable updatedAt info', () => { + const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); + + expect(updatedAtEl.exists()).toBe(true); + expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am GMT+0000'); + expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js new file mode 100644 index 00000000000..34184522b55 --- /dev/null +++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js @@ -0,0 +1,160 @@ +import { mount } from '@vue/test-utils'; +import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; + +import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue'; +import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue'; +import IssuableItem from '~/issuable_list/components/issuable_item.vue'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +import { mockIssuableListProps } from '../mock_data'; + +const createComponent = (propsData = mockIssuableListProps) => + mount(IssuableListRoot, { + propsData, + slots: { + 'nav-actions': ` + <button class="js-new-issuable">New issuable</button> + `, + 'empty-state': ` + <p class="js-issuable-empty-state">Issuable empty state</p> + `, + }, + }); + +describe('IssuableListRoot', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('renders component container element with class "issuable-list-container"', () => { + expect(wrapper.classes()).toContain('issuable-list-container'); + }); + + it('renders issuable-tabs component', () => { + const tabsEl = wrapper.find(IssuableTabs); + + expect(tabsEl.exists()).toBe(true); + expect(tabsEl.props()).toMatchObject({ + tabs: wrapper.vm.tabs, + tabCounts: wrapper.vm.tabCounts, + currentTab: wrapper.vm.currentTab, + }); + }); + + it('renders contents for slot "nav-actions" within issuable-tab component', () => { + const buttonEl = wrapper.find(IssuableTabs).find('button.js-new-issuable'); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.text()).toBe('New issuable'); + }); + + it('renders filtered-search-bar component', () => { + const searchEl = wrapper.find(FilteredSearchBar); + const { + namespace, + recentSearchesStorageKey, + searchInputPlaceholder, + searchTokens, + sortOptions, + initialFilterValue, + initialSortBy, + } = wrapper.vm; + + expect(searchEl.exists()).toBe(true); + expect(searchEl.props()).toMatchObject({ + namespace, + recentSearchesStorageKey, + searchInputPlaceholder, + tokens: searchTokens, + sortOptions, + initialFilterValue, + initialSortBy, + }); + }); + + it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => { + wrapper.setProps({ + issuablesLoading: true, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders issuable-item component for each item within `issuables` array', () => { + const itemsEl = wrapper.findAll(IssuableItem); + const mockIssuable = mockIssuableListProps.issuables[0]; + + expect(itemsEl).toHaveLength(mockIssuableListProps.issuables.length); + expect(itemsEl.at(0).props()).toMatchObject({ + issuableSymbol: wrapper.vm.issuableSymbol, + issuable: mockIssuable, + }); + }); + + it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => { + wrapper.setProps({ + issuables: [], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true); + expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state'); + }); + + it('renders gl-pagination when `showPaginationControls` prop is true', async () => { + wrapper.setProps({ + showPaginationControls: true, + }); + + await wrapper.vm.$nextTick(); + + const paginationEl = wrapper.find(GlPagination); + expect(paginationEl.exists()).toBe(true); + expect(paginationEl.props()).toMatchObject({ + perPage: 20, + value: 1, + prevPage: 0, + nextPage: 2, + align: 'center', + }); + }); + }); + + describe('events', () => { + it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => { + wrapper.find(IssuableTabs).vm.$emit('click'); + + expect(wrapper.emitted('click-tab')).toBeTruthy(); + }); + + it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => { + const searchEl = wrapper.find(FilteredSearchBar); + + searchEl.vm.$emit('onFilter'); + expect(wrapper.emitted('filter')).toBeTruthy(); + searchEl.vm.$emit('onSort'); + expect(wrapper.emitted('sort')).toBeTruthy(); + }); + + it('gl-pagination component emits `page-change` event on `input` event', async () => { + wrapper.setProps({ + showPaginationControls: true, + }); + + await wrapper.vm.$nextTick(); + + wrapper.find(GlPagination).vm.$emit('input'); + expect(wrapper.emitted('page-change')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_tabs_spec.js b/spec/frontend/issuable_list/components/issuable_tabs_spec.js new file mode 100644 index 00000000000..12611400084 --- /dev/null +++ b/spec/frontend/issuable_list/components/issuable_tabs_spec.js @@ -0,0 +1,91 @@ +import { mount } from '@vue/test-utils'; +import { GlTab, GlBadge } from '@gitlab/ui'; + +import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue'; + +import { mockIssuableListProps } from '../mock_data'; + +const createComponent = ({ + tabs = mockIssuableListProps.tabs, + tabCounts = mockIssuableListProps.tabCounts, + currentTab = mockIssuableListProps.currentTab, +} = {}) => + mount(IssuableTabs, { + propsData: { + tabs, + tabCounts, + currentTab, + }, + slots: { + 'nav-actions': ` + <button class="js-new-issuable">New issuable</button> + `, + }, + }); + +describe('IssuableTabs', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('methods', () => { + describe('isTabActive', () => { + it.each` + tabName | currentTab | returnValue + ${'opened'} | ${'opened'} | ${true} + ${'opened'} | ${'closed'} | ${false} + `( + 'returns $returnValue when tab name is "$tabName" is current tab is "$currentTab"', + async ({ tabName, currentTab, returnValue }) => { + wrapper.setProps({ + currentTab, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isTabActive(tabName)).toBe(returnValue); + }, + ); + }); + }); + + describe('template', () => { + it('renders gl-tab for each tab within `tabs` array', () => { + const tabsEl = wrapper.findAll(GlTab); + + expect(tabsEl.exists()).toBe(true); + expect(tabsEl).toHaveLength(mockIssuableListProps.tabs.length); + }); + + it('renders gl-badge component within a tab', () => { + const badgeEl = wrapper.findAll(GlBadge).at(0); + + expect(badgeEl.exists()).toBe(true); + expect(badgeEl.text()).toBe(`${mockIssuableListProps.tabCounts.opened}`); + }); + + it('renders contents for slot "nav-actions"', () => { + const buttonEl = wrapper.find('button.js-new-issuable'); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.text()).toBe('New issuable'); + }); + }); + + describe('events', () => { + it('gl-tab component emits `click` event on `click` event', () => { + const tabEl = wrapper.findAll(GlTab).at(0); + + tabEl.vm.$emit('click', 'opened'); + + expect(wrapper.emitted('click')).toBeTruthy(); + expect(wrapper.emitted('click')[0]).toEqual(['opened']); + }); + }); +}); diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js new file mode 100644 index 00000000000..f6f914a595d --- /dev/null +++ b/spec/frontend/issuable_list/mock_data.js @@ -0,0 +1,135 @@ +import { + mockAuthorToken, + mockLabelToken, + mockSortOptions, +} from 'jest/vue_shared/components/filtered_search_bar/mock_data'; + +export const mockAuthor = { + id: 'gid://gitlab/User/1', + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://0.0.0.0:3000/root', +}; + +export const mockRegularLabel = { + id: 'gid://gitlab/GroupLabel/2048', + title: 'Documentation Update', + description: null, + color: '#F0AD4E', + textColor: '#FFFFFF', +}; + +export const mockScopedLabel = { + id: 'gid://gitlab/ProjectLabel/2049', + title: 'status::confirmed', + description: null, + color: '#D9534F', + textColor: '#FFFFFF', +}; + +export const mockLabels = [mockRegularLabel, mockScopedLabel]; + +export const mockIssuable = { + iid: '30', + title: 'Dismiss Cipher with no integrity', + description: null, + createdAt: '2020-06-29T13:52:56Z', + updatedAt: '2020-09-10T11:41:13Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/30', + author: mockAuthor, + labels: { + nodes: mockLabels, + }, +}; + +export const mockIssuables = [ + mockIssuable, + { + iid: '28', + title: 'Dismiss Cipher with no integrity', + description: null, + createdAt: '2020-06-29T13:52:56Z', + updatedAt: '2020-06-29T13:52:56Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/28', + author: mockAuthor, + labels: { + nodes: [], + }, + }, + { + iid: '7', + title: 'Temporibus in veritatis labore explicabo velit molestiae sed.', + description: 'Quo consequatur rem aliquid laborum quibusdam molestiae saepe.', + createdAt: '2020-06-25T13:50:14Z', + updatedAt: '2020-08-25T06:09:27Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/7', + author: mockAuthor, + labels: { + nodes: mockLabels, + }, + }, + { + iid: '17', + title: 'Vel voluptatem quaerat est hic incidunt qui ut aliquid sit exercitationem.', + description: 'Incidunt accusamus perspiciatis aut excepturi.', + createdAt: '2020-06-19T13:51:36Z', + updatedAt: '2020-08-11T13:36:35Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/17', + author: mockAuthor, + labels: { + nodes: [], + }, + }, + { + iid: '16', + title: 'Vero qui quo labore libero omnis quisquam et cumque.', + description: 'Ipsa ipsum magni nostrum alias aut exercitationem.', + createdAt: '2020-06-19T13:51:36Z', + updatedAt: '2020-06-19T13:51:36Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/16', + author: mockAuthor, + labels: { + nodes: [], + }, + }, +]; + +export const mockTabs = [ + { + id: 'state-opened', + name: 'opened', + title: 'Open', + titleTooltip: 'Filter by issuables that are currently opened.', + }, + { + id: 'state-archived', + name: 'closed', + title: 'Closed', + titleTooltip: 'Filter by issuables that are currently archived.', + }, + { + id: 'state-all', + name: 'all', + title: 'All', + titleTooltip: 'Show all issuables.', + }, +]; + +export const mockTabCounts = { + opened: 5, + closed: 0, + all: 5, +}; + +export const mockIssuableListProps = { + namespace: 'gitlab-org/gitlab-test', + recentSearchesStorageKey: 'issues', + searchInputPlaceholder: 'Search issues', + searchTokens: [mockAuthorToken, mockLabelToken], + sortOptions: mockSortOptions, + issuables: mockIssuables, + tabs: mockTabs, + tabCounts: mockTabCounts, + currentTab: 'opened', +}; diff --git a/spec/lib/gitlab/background_migration/migrate_to_hashed_storage_spec.rb b/spec/lib/gitlab/background_migration/migrate_to_hashed_storage_spec.rb new file mode 100644 index 00000000000..0f7bb06e830 --- /dev/null +++ b/spec/lib/gitlab/background_migration/migrate_to_hashed_storage_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# rubocop:disable RSpec/FactoriesInMigrationSpecs +RSpec.describe Gitlab::BackgroundMigration::MigrateToHashedStorage, :sidekiq, :redis do + let(:migrator) { Gitlab::HashedStorage::Migrator.new } + + subject(:background_migration) { described_class.new } + + describe '#perform' do + let!(:project) { create(:project, :empty_repo, :legacy_storage) } + + context 'with pending rollback' do + it 'aborts rollback operation' do + Sidekiq::Testing.disable! do + Sidekiq::Client.push( + 'queue' => ::HashedStorage::ProjectRollbackWorker.queue, + 'class' => ::HashedStorage::ProjectRollbackWorker, + 'args' => [project.id] + ) + + expect { background_migration.perform }.to change { migrator.rollback_pending? }.from(true).to(false) + end + end + end + + it 'enqueues legacy projects to be migrated' do + Sidekiq::Testing.fake! do + expect { background_migration.perform }.to change { Sidekiq::Queues[::HashedStorage::MigratorWorker.queue].size }.by(1) + end + end + + context 'when executing all jobs' do + it 'migrates legacy projects' do + Sidekiq::Testing.inline! do + expect { background_migration.perform }.to change { project.reload.legacy_storage? }.from(true).to(false) + end + end + end + end +end +# rubocop:enable RSpec/FactoriesInMigrationSpecs diff --git a/spec/lib/gitlab/hashed_storage/migrator_spec.rb b/spec/lib/gitlab/hashed_storage/migrator_spec.rb index 0549b3128c7..f4f15cab05a 100644 --- a/spec/lib/gitlab/hashed_storage/migrator_spec.rb +++ b/spec/lib/gitlab/hashed_storage/migrator_spec.rb @@ -232,4 +232,16 @@ RSpec.describe Gitlab::HashedStorage::Migrator, :redis do expect(subject.rollback_pending?).to be_falsey end end + + describe 'abort_rollback!' do + let_it_be(:project) { create(:project, :empty_repo) } + + it 'removes any rollback related scheduled job' do + Sidekiq::Testing.disable! do + ::HashedStorage::RollbackerWorker.perform_async(1, 5) + + expect { subject.abort_rollback! }.to change { subject.rollback_pending? }.from(true).to(false) + end + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 5f691799e9c..b3836e5dec7 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -299,6 +299,7 @@ protected_branches: - merge_access_levels - push_access_levels - unprotect_access_levels +- approval_project_rules protected_tags: - project - create_access_levels diff --git a/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb b/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb new file mode 100644 index 00000000000..20ba2fbccea --- /dev/null +++ b/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20200915044225_schedule_migration_to_hashed_storage.rb') + +RSpec.describe ScheduleMigrationToHashedStorage, :sidekiq do + describe '#up' do + it 'schedules background migration job' do + Sidekiq::Testing.fake! do + expect { migrate! }.to change { BackgroundMigrationWorker.jobs.size }.by(1) + end + end + end +end diff --git a/spec/services/projects/open_issues_count_service_spec.rb b/spec/services/projects/open_issues_count_service_spec.rb index c739fea5ecf..294c9adcc92 100644 --- a/spec/services/projects/open_issues_count_service_spec.rb +++ b/spec/services/projects/open_issues_count_service_spec.rb @@ -10,6 +10,14 @@ RSpec.describe Projects::OpenIssuesCountService, :use_clean_rails_memory_store_c it_behaves_like 'a counter caching service' describe '#count' do + it 'does not count test cases' do + create(:issue, :opened, project: project) + create(:incident, :opened, project: project) + create(:quality_test_case, :opened, project: project) + + expect(described_class.new(project).count).to eq(2) + end + context 'when user is nil' do it 'does not include confidential issues in the issue count' do create(:issue, :opened, project: project) diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb index 223f5aea813..1be6e86b650 100644 --- a/spec/workers/git_garbage_collect_worker_spec.rb +++ b/spec/workers/git_garbage_collect_worker_spec.rb @@ -25,12 +25,18 @@ RSpec.describe GitGarbageCollectWorker do end shared_examples 'it updates the project statistics' do - specify do - expect_any_instance_of(Projects::UpdateStatisticsService).to receive(:execute).and_call_original - expect(Projects::UpdateStatisticsService) - .to receive(:new) - .with(project, nil, statistics: [:repository_size, :lfs_objects_size]) - .and_call_original + it 'updates the project statistics' do + expect_next_instance_of(Projects::UpdateStatisticsService, project, nil, statistics: [:repository_size, :lfs_objects_size]) do |service| + expect(service).to receive(:execute).and_call_original + end + + subject.perform(*params) + end + + it 'does nothing if the database is read-only' do + allow(Gitlab::Database).to receive(:read_only?) { true } + + expect_any_instance_of(Projects::UpdateStatisticsService).not_to receive(:execute) subject.perform(*params) end @@ -141,7 +147,8 @@ RSpec.describe GitGarbageCollectWorker do end it 'does nothing if the database is read-only' do - expect(Gitlab::Database).to receive(:read_only?) { true } + allow(Gitlab::Database).to receive(:read_only?) { true } + expect_any_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences).not_to receive(:run!) subject.perform(*params) |