diff options
107 files changed, 2217 insertions, 801 deletions
diff --git a/.gitlab/ci/reports.gitlab-ci.yml b/.gitlab/ci/reports.gitlab-ci.yml index 4c183c297d5..65abb6c5cba 100644 --- a/.gitlab/ci/reports.gitlab-ci.yml +++ b/.gitlab/ci/reports.gitlab-ci.yml @@ -94,9 +94,9 @@ dependency_scanning: stage: test needs: [] variables: + DS_MAJOR_VERSION: 2 DS_EXCLUDED_PATHS: "qa/qa/ee/fixtures/secure_premade_reports,spec,ee/spec" # GitLab-specific script: - - export DS_VERSION=${SP_VERSION:-$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')} - | if ! docker info &>/dev/null; then if [ -z "$DOCKER_HOST" -a "$KUBERNETES_PORT" ]; then @@ -138,7 +138,7 @@ dependency_scanning: ) \ --volume "$PWD:/code" \ --volume /var/run/docker.sock:/var/run/docker.sock \ - "registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$DS_VERSION" /code + "registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$DS_MAJOR_VERSION" /code artifacts: paths: - gl-dependency-scanning-report.json # GitLab-specific diff --git a/.rubocop.yml b/.rubocop.yml index 3b4a7a9369d..ed17799478a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -182,6 +182,9 @@ Rails/ApplicationRecord: - ee/db/**/*.rb - ee/spec/**/*.rb +Cop/DefaultScope: + Enabled: true + Rails/FindBy: Enabled: true Include: diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 7c001b755f3..198c4c9fd77 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -40,13 +40,11 @@ class ListIssue { } removeLabel(removeLabel) { - if (removeLabel) { - this.labels = this.labels.filter(label => removeLabel.id !== label.id); - } + boardsStore.removeIssueLabel(this, removeLabel); } removeLabels(labels) { - labels.forEach(this.removeLabel.bind(this)); + boardsStore.removeIssueLabels(this, labels); } addAssignee(assignee) { @@ -54,7 +52,7 @@ class ListIssue { } findAssignee(findAssignee) { - return this.assignees.find(assignee => assignee.id === findAssignee.id); + return boardsStore.findIssueAssignee(this, findAssignee); } removeAssignee(removeAssignee) { @@ -66,10 +64,7 @@ class ListIssue { } addMilestone(milestone) { - const miletoneId = this.milestone ? this.milestone.id : null; - if (IS_EE && milestone.id !== miletoneId) { - this.milestone = new ListMilestone(milestone); - } + boardsStore.addIssueMilestone(this, milestone); } removeMilestone(removeMilestone) { diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index d8257dd3e91..0bd606c6297 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -158,18 +158,7 @@ class List { } removeMultipleIssues(removeIssues) { - const ids = removeIssues.map(issue => issue.id); - - this.issues = this.issues.filter(issue => { - const matchesRemove = ids.includes(issue.id); - - if (matchesRemove) { - this.issuesSize -= 1; - issue.removeLabel(this.label); - } - - return !matchesRemove; - }); + return boardsStore.removeListMultipleIssues(this, removeIssues); } removeIssue(removeIssue) { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index fb3d2b81060..fba9859f3a4 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -277,6 +277,20 @@ const boardsStore = { return !matchesRemove; }); }, + removeListMultipleIssues(list, removeIssues) { + const ids = removeIssues.map(issue => issue.id); + + list.issues = list.issues.filter(issue => { + const matchesRemove = ids.includes(issue.id); + + if (matchesRemove) { + list.issuesSize -= 1; + issue.removeLabel(list.label); + } + + return !matchesRemove; + }); + }, startMoving(list, issue) { Object.assign(this.moving, { list, issue }); @@ -684,6 +698,11 @@ const boardsStore = { ), ); }, + removeIssueLabel(issue, removeLabel) { + if (removeLabel) { + issue.labels = issue.labels.filter(label => removeLabel.id !== label.id); + } + }, addIssueAssignee(issue, assignee) { if (!issue.findAssignee(assignee)) { @@ -691,6 +710,10 @@ const boardsStore = { } }, + removeIssueLabels(issue, labels) { + labels.forEach(issue.removeLabel.bind(issue)); + }, + bulkUpdate(issueIds, extraData = {}) { const data = { update: Object.assign(extraData, { @@ -763,6 +786,10 @@ const boardsStore = { } }, + findIssueAssignee(issue, findAssignee) { + return issue.assignees.find(assignee => assignee.id === findAssignee.id); + }, + clearMultiSelect() { this.multiSelect.list = []; }, @@ -771,6 +798,13 @@ const boardsStore = { issue.assignees = []; }, + addIssueMilestone(issue, milestone) { + const miletoneId = issue.milestone ? issue.milestone.id : null; + if (IS_EE && milestone.id !== miletoneId) { + issue.milestone = new ListMilestone(milestone); + } + }, + refreshIssueData(issue, obj) { issue.id = obj.id; issue.iid = obj.iid; diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index f0988adbd5d..92d25709bd5 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -1,6 +1,7 @@ <script> import { mapGetters } from 'vuex'; import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; +import { getFileEOL } from '../utils'; export default { components: { @@ -8,6 +9,9 @@ export default { }, computed: { ...mapGetters(['activeFile']), + activeFileEOL() { + return getFileEOL(this.activeFile.content); + }, }, }; </script> @@ -16,7 +20,7 @@ export default { <div class="ide-status-list d-flex"> <template v-if="activeFile"> <div class="ide-status-file">{{ activeFile.name }}</div> - <div class="ide-status-file">{{ activeFile.eol }}</div> + <div class="ide-status-file">{{ activeFileEOL }}</div> <div v-if="!activeFile.binary" class="ide-status-file"> {{ activeFile.editorRow }}:{{ activeFile.editorColumn }} </div> diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 7261e0590c8..b2141c13d9f 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -35,7 +35,6 @@ export default { name: `${this.path ? `${this.path}/` : ''}${name}`, type: 'blob', content, - base64: !isText, 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 6f38569b021..07d14fd0338 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -83,10 +83,6 @@ export default { active: this.isPreviewViewMode, }; }, - fileType() { - const info = viewerInformationForPath(this.file.path); - return (info && info.id) || ''; - }, showEditor() { return !this.shouldHideEditor && this.isEditorViewMode; }, @@ -99,6 +95,12 @@ export default { currentBranchCommit() { return this.currentBranch?.commit.id; }, + previewMode() { + return viewerInformationForPath(this.file.path); + }, + fileType() { + return this.previewMode?.id || ''; + }, }, watch: { file(newVal, oldVal) { @@ -181,7 +183,6 @@ export default { 'setFileLanguage', 'setEditorPosition', 'setFileViewMode', - 'setFileEOL', 'updateViewer', 'removePendingTab', 'triggerFilesChange', @@ -260,7 +261,6 @@ export default { const monacoModel = model.getModel(); const content = monacoModel.getValue(); this.changeFileContent({ path: file.path, content }); - this.setFileEOL({ eol: this.model.eol }); }); // Handle Cursor Position @@ -280,11 +280,6 @@ export default { this.setFileLanguage({ fileLanguage: this.model.language, }); - - // Get File eol - this.setFileEOL({ - eol: this.model.eol, - }); }, refreshEditorDimensions() { if (this.showEditor) { @@ -331,16 +326,15 @@ export default { role="button" @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })" > - <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template> - <template v-else>{{ __('Review') }}</template> + {{ __('Edit') }} </a> </li> - <li v-if="file.previewMode" :class="previewTabCSS"> + <li v-if="previewMode" :class="previewTabCSS"> <a href="javascript:void(0);" role="button" @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })" - >{{ file.previewMode.previewTitle }}</a + >{{ previewMode.previewTitle }}</a > </li> </ul> diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 4c743076fb2..c5bb00c3dee 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -53,10 +53,6 @@ export default class Model { return this.model.getModeId(); } - get eol() { - return this.model.getEOL() === '\n' ? 'LF' : 'CRLF'; - } - get path() { return this.file.key; } diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index 46b37b50f46..6d85e225fd5 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -19,7 +19,6 @@ export const decorateFiles = ({ branchId, tempFile = false, content = '', - base64 = false, binary = false, rawPath = '', }) => { @@ -88,10 +87,8 @@ export const decorateFiles = ({ tempFile, changed: tempFile, content, - base64, binary: (previewMode && previewMode.binary) || binary, rawPath, - previewMode, parentPath, }); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 423417f8fdb..c881f1221e5 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -29,7 +29,6 @@ export const createTempEntry = ( name, type, content = '', - base64 = false, binary = false, rawPath = '', openFile = true, @@ -60,7 +59,6 @@ export const createTempEntry = ( type, tempFile: true, content, - base64, binary, rawPath, }); @@ -92,7 +90,6 @@ export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) => name: getters.getAvailableFileName(name), type: 'blob', content: rawPath.split('base64,')[1], - base64: true, binary: true, rawPath, openFile: false, diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 397decd479d..47f9337a288 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -169,12 +169,6 @@ export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => { } }; -export const setFileEOL = ({ getters, commit }, { eol }) => { - if (getters.activeFile) { - commit(types.SET_FILE_EOL, { file: getters.activeFile, eol }); - } -}; - export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => { if (getters.activeFile) { commit(types.SET_FILE_POSITION, { diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index c49b723b4bf..d94adc3760f 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -40,7 +40,6 @@ export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_POSITION = 'SET_FILE_POSITION'; export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE'; -export const SET_FILE_EOL = 'SET_FILE_EOL'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED'; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 313fa1fe029..c90bc2a3320 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -99,11 +99,6 @@ export default { fileLanguage, }); }, - [types.SET_FILE_EOL](state, { file, eol }) { - Object.assign(state.entries[file.path], { - eol, - }); - }, [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) { Object.assign(state.entries[file.path], { editorRow, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 012efa77b96..1c5fe9fe9a5 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,5 +1,10 @@ import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants'; -import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility'; +import { + relativePathToAbsolute, + isAbsolute, + isRootRelative, + isBase64DataUrl, +} from '~/lib/utils/url_utility'; export const dataStructure = () => ({ id: '', @@ -31,13 +36,10 @@ export const dataStructure = () => ({ binary: false, raw: '', content: '', - base64: false, editorRow: 1, editorColumn: 1, fileLanguage: '', - eol: '', viewMode: FILE_VIEW_MODE_EDITOR, - previewMode: null, size: 0, parentPath: null, lastOpenedAt: 0, @@ -60,10 +62,8 @@ export const decorateData = entity => { active = false, opened = false, changed = false, - base64 = false, binary = false, rawPath = '', - previewMode, file_lock, parentPath = '', } = entity; @@ -82,10 +82,8 @@ export const decorateData = entity => { active, changed, content, - base64, binary, rawPath, - previewMode, file_lock, parentPath, }); @@ -136,7 +134,7 @@ export const createCommitPayload = ({ file_path: f.path, previous_path: f.prevPath || undefined, content: f.prevPath && !f.changed ? null : f.content || undefined, - encoding: f.base64 ? 'base64' : 'text', + encoding: isBase64DataUrl(f.rawPath) ? 'base64' : 'text', last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha, })), start_sha: newBranch ? rootGetters.lastCommit.id : undefined, diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index ced6ffe0c3e..c28a2bd9f1d 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -119,3 +119,7 @@ export function readFileAsDataURL(file) { reader.readAsDataURL(file); }); } + +export function getFileEOL(content = '') { + return content.includes('\r\n') ? 'CRLF' : 'LF'; +} diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index aac58f285f0..be3fe1ed620 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -90,6 +90,13 @@ export const truncatePathMiddleToLength = (text, maxWidth) => { while (returnText.length >= maxWidth) { const textSplit = returnText.split('/').filter(s => s !== ELLIPSIS_CHAR); + + if (textSplit.length === 0) { + // There are n - 1 path separators for n segments, so 2n - 1 <= maxWidth + const maxSegments = Math.floor((maxWidth + 1) / 2); + return new Array(maxSegments).fill(ELLIPSIS_CHAR).join('/'); + } + const middleIndex = Math.floor(textSplit.length / 2); returnText = textSplit diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 6ae4167d5ba..0472b8cf51f 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -244,6 +244,15 @@ export function isRootRelative(url) { } /** + * Returns true if url is a base64 data URL + * + * @param {String} url + */ +export function isBase64DataUrl(url) { + return /^data:[.\w+-]+\/[.\w+-]+;base64,/.test(url); +} + +/** * Returns true if url is an absolute or root-relative URL * * @param {String} url diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue new file mode 100644 index 00000000000..0c684d124d5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue @@ -0,0 +1,33 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { + EMPTY_IMAGE_REPOSITORY_TITLE, + EMPTY_IMAGE_REPOSITORY_MESSAGE, +} from '../../constants/index'; + +export default { + components: { + GlEmptyState, + }, + props: { + noContainersImage: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + EMPTY_IMAGE_REPOSITORY_TITLE, + EMPTY_IMAGE_REPOSITORY_MESSAGE, + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE" + :svg-path="noContainersImage" + :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE" + class="gl-mx-auto gl-my-0" + /> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue new file mode 100644 index 00000000000..b7afa5fba33 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue @@ -0,0 +1,34 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, +}; +</script> + +<template> + <div> + <gl-skeleton-loader + v-for="index in $options.loader.repeat" + :key="index" + :width="$options.loader.width" + :height="$options.loader.height" + preserve-aspect-ratio="xMinYMax meet" + > + <rect width="15" x="0" y="12.5" height="15" rx="4" /> + <rect width="250" x="25" y="10" height="20" rx="4" /> + <circle cx="290" cy="20" r="10" /> + <rect width="100" x="315" y="10" height="20" rx="4" /> + <rect width="100" x="500" y="10" height="20" rx="4" /> + <rect width="100" x="630" y="10" height="20" rx="4" /> + <rect x="960" y="0" width="40" height="40" rx="4" /> + </gl-skeleton-loader> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue new file mode 100644 index 00000000000..81be778e1e5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue @@ -0,0 +1,210 @@ +<script> +import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { + LIST_KEY_TAG, + LIST_KEY_IMAGE_ID, + LIST_KEY_SIZE, + LIST_KEY_LAST_UPDATED, + LIST_KEY_ACTIONS, + LIST_KEY_CHECKBOX, + LIST_LABEL_TAG, + LIST_LABEL_IMAGE_ID, + LIST_LABEL_SIZE, + LIST_LABEL_LAST_UPDATED, + REMOVE_TAGS_BUTTON_TITLE, + REMOVE_TAG_BUTTON_TITLE, +} from '../../constants/index'; + +export default { + components: { + GlTable, + GlFormCheckbox, + GlButton, + ClipboardButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + tags: { + type: Array, + required: false, + default: () => [], + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + isDesktop: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + REMOVE_TAGS_BUTTON_TITLE, + REMOVE_TAG_BUTTON_TITLE, + }, + data() { + return { + selectedItems: [], + }; + }, + computed: { + fields() { + const tagClass = this.isDesktop ? 'w-25' : ''; + const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end'; + return [ + { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' }, + { + key: LIST_KEY_TAG, + label: LIST_LABEL_TAG, + class: `${tagClass} js-tag-column`, + innerClass: tagInnerClass, + }, + { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, + { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, + { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, + { key: LIST_KEY_ACTIONS, label: '' }, + ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); + }, + tagsNames() { + return this.tags.map(t => t.name); + }, + selectAllChecked() { + return this.selectedItems.length === this.tags.length && this.tags.length > 0; + }, + }, + watch: { + tagsNames: { + immediate: false, + handler(tagsNames) { + this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t)); + }, + }, + }, + methods: { + formatSize(size) { + return numberToHumanSize(size); + }, + layers(layers) { + return layers ? n__('%d layer', '%d layers', layers) : ''; + }, + onSelectAllChange() { + if (this.selectAllChecked) { + this.selectedItems = []; + } else { + this.selectedItems = this.tags.map(x => x.name); + } + }, + updateSelectedItems(name) { + const delIndex = this.selectedItems.findIndex(x => x === name); + + if (delIndex > -1) { + this.selectedItems.splice(delIndex, 1); + } else { + this.selectedItems.push(name); + } + }, + }, +}; +</script> + +<template> + <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading"> + <template v-if="isDesktop" #head(checkbox)> + <gl-form-checkbox + data-testid="mainCheckbox" + :checked="selectAllChecked" + @change="onSelectAllChange" + /> + </template> + <template #head(actions)> + <span class="gl-display-flex gl-justify-content-end"> + <gl-button + v-gl-tooltip + data-testid="bulkDeleteButton" + :disabled="!selectedItems || selectedItems.length === 0" + icon="remove" + variant="danger" + :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" + :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" + @click="$emit('delete', selectedItems)" + /> + </span> + </template> + + <template #cell(checkbox)="{item}"> + <gl-form-checkbox + data-testid="rowCheckbox" + :checked="selectedItems.includes(item.name)" + @change="updateSelectedItems(item.name)" + /> + </template> + <template #cell(name)="{item, field}"> + <div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']"> + <span + v-gl-tooltip + data-testid="rowNameText" + :title="item.name" + class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" + > + {{ item.name }} + </span> + <clipboard-button + v-if="item.location" + data-testid="rowClipboardButton" + :title="item.location" + :text="item.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + </div> + </template> + <template #cell(short_revision)="{value}"> + <span data-testid="rowShortRevision"> + {{ value }} + </span> + </template> + <template #cell(total_size)="{item}"> + <span data-testid="rowSize"> + {{ formatSize(item.total_size) }} + <template v-if="item.total_size && item.layers"> + · + </template> + {{ layers(item.layers) }} + </span> + </template> + <template #cell(created_at)="{value}"> + <span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)"> + {{ timeFormatted(value) }} + </span> + </template> + <template #cell(actions)="{item}"> + <span class="gl-display-flex gl-justify-content-end"> + <gl-button + data-testid="singleDeleteButton" + :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" + :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE" + :disabled="!item.destroy_path" + variant="danger" + icon="remove" + category="secondary" + @click="$emit('delete', [item.name])" + /> + </span> + </template> + + <template #empty> + <slot name="empty"></slot> + </template> + <template #table-busy> + <slot name="loader"></slot> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index f1252b24f6a..cd878c38081 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -125,7 +125,7 @@ export default { :disabled="disabledDelete" :title="$options.i18n.REMOVE_REPOSITORY_LABEL" :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL" - class="btn-inverted" + category="secondary" variant="danger" icon="remove" @click="$emit('delete', item)" diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 31dbc1c8203..598e643ce1a 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -1,41 +1,17 @@ <script> -import { mapState, mapActions, mapGetters } from 'vuex'; -import { - GlTable, - GlFormCheckbox, - GlDeprecatedButton, - GlIcon, - GlTooltipDirective, - GlPagination, - GlEmptyState, - GlResizeObserverDirective, - GlSkeletonLoader, -} from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { n__ } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; import Tracking from '~/tracking'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; +import TagsTable from '../components/details_page/tags_table.vue'; +import TagsLoader from '../components/details_page/tags_loader.vue'; +import EmptyTagsState from '../components/details_page/empty_tags_state.vue'; + import { decodeAndParse } from '../utils'; import { - LIST_KEY_TAG, - LIST_KEY_IMAGE_ID, - LIST_KEY_SIZE, - LIST_KEY_LAST_UPDATED, - LIST_KEY_ACTIONS, - LIST_KEY_CHECKBOX, - LIST_LABEL_TAG, - LIST_LABEL_IMAGE_ID, - LIST_LABEL_SIZE, - LIST_LABEL_LAST_UPDATED, - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - EMPTY_IMAGE_REPOSITORY_TITLE, - EMPTY_IMAGE_REPOSITORY_MESSAGE, ALERT_SUCCESS_TAG, ALERT_DANGER_TAG, ALERT_SUCCESS_TAGS, @@ -46,66 +22,29 @@ export default { components: { DeleteAlert, DetailsHeader, - GlTable, - GlFormCheckbox, - GlDeprecatedButton, - GlIcon, - ClipboardButton, GlPagination, DeleteModal, - GlSkeletonLoader, - GlEmptyState, + TagsTable, + TagsLoader, + EmptyTagsState, }, directives: { - GlTooltip: GlTooltipDirective, GlResizeObserver: GlResizeObserverDirective, }, - mixins: [timeagoMixin, Tracking.mixin()], - loader: { - repeat: 10, - width: 1000, - height: 40, - }, - i18n: { - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - EMPTY_IMAGE_REPOSITORY_TITLE, - EMPTY_IMAGE_REPOSITORY_MESSAGE, - }, + mixins: [Tracking.mixin()], data() { return { - selectedItems: [], itemsToBeDeleted: [], - selectAllChecked: false, - modalDescription: null, isDesktop: true, deleteAlertType: null, }; }, computed: { - ...mapGetters(['tags']), - ...mapState(['tagsPagination', 'isLoading', 'config']), + ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']), imageName() { const { name } = decodeAndParse(this.$route.params.id); return name; }, - fields() { - const tagClass = this.isDesktop ? 'w-25' : ''; - const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end'; - return [ - { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' }, - { - key: LIST_KEY_TAG, - label: LIST_LABEL_TAG, - class: `${tagClass} js-tag-column`, - innerClass: tagInnerClass, - }, - { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, - { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, - { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, - { key: LIST_KEY_ACTIONS, label: '' }, - ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); - }, tracking() { return { label: @@ -126,48 +65,8 @@ export default { }, methods: { ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), - formatSize(size) { - return numberToHumanSize(size); - }, - layers(layers) { - return layers ? n__('%d layer', '%d layers', layers) : ''; - }, - onSelectAllChange() { - if (this.selectAllChecked) { - this.deselectAll(); - } else { - this.selectAll(); - } - }, - selectAll() { - this.selectedItems = this.tags.map(x => x.name); - this.selectAllChecked = true; - }, - deselectAll() { - this.selectedItems = []; - this.selectAllChecked = false; - }, - updateSelectedItems(name) { - const delIndex = this.selectedItems.findIndex(x => x === name); - - if (delIndex > -1) { - this.selectedItems.splice(delIndex, 1); - this.selectAllChecked = false; - } else { - this.selectedItems.push(name); - - if (this.selectedItems.length === this.tags.length) { - this.selectAllChecked = true; - } - } - }, - deleteSingleItem(name) { - this.itemsToBeDeleted = [{ ...this.tags.find(t => t.name === name) }]; - this.track('click_button'); - this.$refs.deleteModal.show(); - }, - deleteMultipleItems() { - this.itemsToBeDeleted = this.selectedItems.map(name => ({ + deleteTags(toBeDeletedList) { + this.itemsToBeDeleted = toBeDeletedList.map(name => ({ ...this.tags.find(t => t.name === name), })); this.track('click_button'); @@ -176,7 +75,6 @@ export default { handleSingleDelete() { const [itemToDelete] = this.itemsToBeDeleted; this.itemsToBeDeleted = []; - this.selectedItems = this.selectedItems.filter(name => name !== itemToDelete.name); return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id }) .then(() => { this.deleteAlertType = ALERT_SUCCESS_TAG; @@ -188,7 +86,6 @@ export default { handleMultipleDelete() { const { itemsToBeDeleted } = this; this.itemsToBeDeleted = []; - this.selectedItems = []; return this.requestDeleteTags({ ids: itemsToBeDeleted.map(x => x.name), @@ -227,116 +124,14 @@ export default { <details-header :image-name="imageName" /> - <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty> - <template v-if="isDesktop" #head(checkbox)> - <gl-form-checkbox - ref="mainCheckbox" - :checked="selectAllChecked" - @change="onSelectAllChange" - /> - </template> - <template #head(actions)> - <gl-deprecated-button - ref="bulkDeleteButton" - v-gl-tooltip - :disabled="!selectedItems || selectedItems.length === 0" - class="float-right" - variant="danger" - :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - @click="deleteMultipleItems()" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </template> - - <template #cell(checkbox)="{item}"> - <gl-form-checkbox - ref="rowCheckbox" - class="js-row-checkbox" - :checked="selectedItems.includes(item.name)" - @change="updateSelectedItems(item.name)" - /> - </template> - <template #cell(name)="{item, field}"> - <div ref="rowName" :class="[field.innerClass, 'gl-display-flex']"> - <span - v-gl-tooltip - data-testid="rowNameText" - :title="item.name" - class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" - > - {{ item.name }} - </span> - <clipboard-button - v-if="item.location" - ref="rowClipboardButton" - :title="item.location" - :text="item.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> - </template> - <template #cell(short_revision)="{value}"> - <span ref="rowShortRevision"> - {{ value }} - </span> - </template> - <template #cell(total_size)="{item}"> - <span ref="rowSize"> - {{ formatSize(item.total_size) }} - <template v-if="item.total_size && item.layers"> - · - </template> - {{ layers(item.layers) }} - </span> - </template> - <template #cell(created_at)="{value}"> - <span ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)"> - {{ timeFormatted(value) }} - </span> - </template> - <template #cell(actions)="{item}"> - <gl-deprecated-button - ref="singleDeleteButton" - :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :disabled="!item.destroy_path" - variant="danger" - class="js-delete-registry float-right btn-inverted btn-border-color btn-icon" - @click="deleteSingleItem(item.name)" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </template> - + <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags"> <template #empty> - <template v-if="isLoading"> - <gl-skeleton-loader - v-for="index in $options.loader.repeat" - :key="index" - :width="$options.loader.width" - :height="$options.loader.height" - preserve-aspect-ratio="xMinYMax meet" - > - <rect width="15" x="0" y="12.5" height="15" rx="4" /> - <rect width="250" x="25" y="10" height="20" rx="4" /> - <circle cx="290" cy="20" r="10" /> - <rect width="100" x="315" y="10" height="20" rx="4" /> - <rect width="100" x="500" y="10" height="20" rx="4" /> - <rect width="100" x="630" y="10" height="20" rx="4" /> - <rect x="960" y="0" width="40" height="40" rx="4" /> - </gl-skeleton-loader> - </template> - <gl-empty-state - v-else - :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE" - :svg-path="config.noContainersImage" - :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE" - class="mx-auto my-0" - /> + <empty-tags-state :no-containers-image="config.noContainersImage" /> + </template> + <template #loader> + <tags-loader v-once /> </template> - </gl-table> + </tags-table> <gl-pagination v-if="!isLoading" diff --git a/app/assets/javascripts/registry/explorer/stores/getters.js b/app/assets/javascripts/registry/explorer/stores/getters.js index a371d0e6356..7b5d1bd6da3 100644 --- a/app/assets/javascripts/registry/explorer/stores/getters.js +++ b/app/assets/javascripts/registry/explorer/stores/getters.js @@ -1,9 +1,3 @@ -export const tags = state => { - // to show the loader inside the table we need to pass an empty array to gl-table whenever the table is loading - // this is to take in account isLoading = true and state.tags =[1,2,3] during pagination and delete - return state.isLoading ? [] : state.tags; -}; - export const dockerBuildCommand = state => { /* eslint-disable @gitlab/require-i18n-strings */ return `docker build -t ${state.config.repositoryUrl} .`; diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index a1d652e42a8..330785c9319 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui'; export default { components: { @@ -7,6 +7,7 @@ export default { GlLink, GlLoadingIcon, GlSprintf, + GlIcon, }, props: { markdownDocsPath: { @@ -59,7 +60,9 @@ export default { </div> <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> + <template> + <gl-icon name="media" :size="16" /> + </template> <span class="attaching-file-message"></span> <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> <span class="uploading-progress">0%</span> @@ -67,7 +70,9 @@ export default { </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> + <template> + <gl-icon name="media" :size="16" /> + </template> </span> <span class="uploading-error-message"></span> @@ -87,8 +92,10 @@ export default { </gl-sprintf> </span> <gl-button class="markdown-selector button-attach-file" variant="link"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i - ><span class="text-attach-file">{{ __('Attach a file') }}</span> + <template> + <gl-icon name="media" :size="16" /> + </template> + <span class="text-attach-file">{{ __('Attach a file') }}</span> </gl-button> <gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link"> {{ __('Cancel') }} diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb new file mode 100644 index 00000000000..a2fc9953c67 --- /dev/null +++ b/app/graphql/types/evidence_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + class EvidenceType < BaseObject + graphql_name 'ReleaseEvidence' + description 'Evidence for a release' + + authorize :download_code + + present_using Releases::EvidencePresenter + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the evidence' + field :sha, GraphQL::STRING_TYPE, null: true, + description: 'SHA1 ID of the evidence hash' + field :filepath, GraphQL::STRING_TYPE, null: true, + description: 'URL from where the evidence can be downloaded' + field :collected_at, Types::TimeType, null: true, + description: 'Timestamp when the evidence was collected' + end +end diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index 30c496f646d..3d8e5a93c68 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -27,6 +27,8 @@ module Types description: 'Assets of the release' field :milestones, Types::MilestoneType.connection_type, null: true, description: 'Milestones associated to the release' + field :evidences, Types::EvidenceType.connection_type, null: true, + description: 'Evidence for the release' field :author, Types::UserType, null: true, description: 'User that created the release' diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index b23c4f71ffa..70a29333b3f 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -65,6 +65,11 @@ module Types calls_gitaly: true, null: false + field :blobs, type: [Types::Snippets::BlobType], + description: 'Snippet blobs', + calls_gitaly: true, + null: false + field :ssh_url_to_repo, type: GraphQL::STRING_TYPE, description: 'SSH URL to the snippet repository', calls_gitaly: true, diff --git a/app/models/badge.rb b/app/models/badge.rb index 3400d6d407d..4339d419b48 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -18,7 +18,7 @@ class Badge < ApplicationRecord # This regex will build the new PLACEHOLDER_REGEX with the new information PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze - default_scope { order_created_at_asc } + default_scope { order_created_at_asc } # rubocop:disable Cop/DefaultScope scope :order_created_at_asc, -> { reorder(created_at: :asc) } diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb index d3ff870e36a..2fcd1708cf4 100644 --- a/app/models/ci/build_dependencies.rb +++ b/app/models/ci/build_dependencies.rb @@ -45,7 +45,7 @@ module Ci end def valid_local? - return true if Feature.enabled?('ci_disable_validates_dependencies') + return true if Feature.enabled?(:ci_disable_validates_dependencies) local.all?(&:valid_dependency?) end diff --git a/app/models/ci/freeze_period.rb b/app/models/ci/freeze_period.rb index bf03b92259a..d215372bb45 100644 --- a/app/models/ci/freeze_period.rb +++ b/app/models/ci/freeze_period.rb @@ -5,7 +5,7 @@ module Ci include StripAttribute self.table_name = 'ci_freeze_periods' - default_scope { order(created_at: :asc) } + default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope belongs_to :project, inverse_of: :freeze_periods diff --git a/app/models/event.rb b/app/models/event.rb index 03a43a6e93c..9c0fcbb354b 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -9,7 +9,7 @@ class Event < ApplicationRecord include Gitlab::Utils::StrongMemoize include UsageStatistics - default_scope { reorder(nil) } + default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope ACTIONS = HashWithIndifferentAccess.new( created: 1, diff --git a/app/models/label.rb b/app/models/label.rb index 652b5e23490..a9256e311ed 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -31,7 +31,7 @@ class Label < ApplicationRecord validates :title, uniqueness: { scope: [:group_id, :project_id] } validates :title, length: { maximum: 255 } - default_scope { order(title: :asc) } + default_scope { order(title: :asc) } # rubocop:disable Cop/DefaultScope scope :templates, -> { where(template: true, type: [Label.name, nil]) } scope :with_title, ->(title) { where(title: title) } diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 431a2ccf416..39b5627d626 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -13,7 +13,7 @@ class GroupMember < Member # Make sure group member points only to group as it source default_value_for :source_type, SOURCE_TYPE validates :source_type, format: { with: /\ANamespace\z/ } - default_scope { where(source_type: SOURCE_TYPE) } + default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) } scope :count_users_by_group_id, -> { joins(:user).group(:source_id).count } diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index fa2e0cb8198..833b27756ab 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -9,7 +9,7 @@ class ProjectMember < Member default_value_for :source_type, SOURCE_TYPE validates :source_type, format: { with: /\AProject\z/ } validates :access_level, inclusion: { in: Gitlab::Access.values } - default_scope { where(source_type: SOURCE_TYPE) } + default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope scope :in_project, ->(project) { where(source_id: project.id) } scope :in_namespaces, ->(groups) do diff --git a/app/models/project_metrics_setting.rb b/app/models/project_metrics_setting.rb index 4886334c53c..689b5a44276 100644 --- a/app/models/project_metrics_setting.rb +++ b/app/models/project_metrics_setting.rb @@ -4,6 +4,7 @@ class ProjectMetricsSetting < ApplicationRecord belongs_to :project validates :external_dashboard_url, + allow_nil: true, length: { maximum: 255 }, addressable_url: { enforce_sanitization: true, ascii_only: true } diff --git a/app/models/releases/evidence.rb b/app/models/releases/evidence.rb index 124fd50f271..7c428f5ad03 100644 --- a/app/models/releases/evidence.rb +++ b/app/models/releases/evidence.rb @@ -1,32 +1,35 @@ # frozen_string_literal: true -class Releases::Evidence < ApplicationRecord - include ShaAttribute - include Presentable +module Releases + class Evidence < ApplicationRecord + include ShaAttribute + include Presentable - belongs_to :release, inverse_of: :evidences + belongs_to :release, inverse_of: :evidences - default_scope { order(created_at: :asc) } + default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope - sha_attribute :summary_sha - alias_attribute :collected_at, :created_at + sha_attribute :summary_sha + alias_attribute :collected_at, :created_at + alias_attribute :sha, :summary_sha - def milestones - @milestones ||= release.milestones.includes(:issues) - end + def milestones + @milestones ||= release.milestones.includes(:issues) + end - ## - # Return `summary` without sensitive information. - # - # Removing issues from summary in order to prevent leaking confidential ones. - # See more https://gitlab.com/gitlab-org/gitlab/issues/121930 - def summary - safe_summary = read_attribute(:summary) + ## + # Return `summary` without sensitive information. + # + # Removing issues from summary in order to prevent leaking confidential ones. + # See more https://gitlab.com/gitlab-org/gitlab/issues/121930 + def summary + safe_summary = read_attribute(:summary) - safe_summary.dig('release', 'milestones')&.each do |milestone| - milestone.delete('issues') - end + safe_summary.dig('release', 'milestones')&.each do |milestone| + milestone.delete('issues') + end - safe_summary + safe_summary + end end end diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb index e468d716239..6b1793a551f 100644 --- a/app/models/repository_language.rb +++ b/app/models/repository_language.rb @@ -4,7 +4,7 @@ class RepositoryLanguage < ApplicationRecord belongs_to :project belongs_to :programming_language - default_scope { includes(:programming_language) } + default_scope { includes(:programming_language) } # rubocop:disable Cop/DefaultScope validates :project, presence: true validates :share, inclusion: { in: 0..100, message: "The share of a language is between 0 and 100" } diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb index faaf7568c72..62a90025ce1 100644 --- a/app/presenters/snippet_presenter.rb +++ b/app/presenters/snippet_presenter.rb @@ -36,10 +36,14 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated end def blob + blobs.first + end + + def blobs if snippet.empty_repo? - snippet.blob + [snippet.blob] else - snippet.blobs.first + snippet.blobs end end diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index c06f572b52f..7aa7ea73639 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -41,9 +41,9 @@ module Projects attribs = params[:metrics_setting_attributes] return {} unless attribs - destroy = attribs[:external_dashboard_url].blank? + attribs[:external_dashboard_url] = attribs[:external_dashboard_url].presence - { metrics_setting_attributes: attribs.merge(_destroy: destroy) } + { metrics_setting_attributes: attribs } end def error_tracking_params diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 92b6174795b..cd9765289a4 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -55,7 +55,7 @@ %span.badge.badge-pill.count= number_with_delimiter(issues_count) %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} } - = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index'], html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index', 'iterations#index'], html_options: { class: "fly-out-top-item" } ) do = link_to issues_group_path(@group) do %strong.fly-out-top-item-name = _('Issues') @@ -85,6 +85,8 @@ %span = _('Milestones') + = render_if_exists 'layouts/nav/sidebar/iterations_link' + - if group_sidebar_link?(:merge_requests) = nav_link(path: 'groups#merge_requests') do = link_to merge_requests_group_path(@group) do diff --git a/changelogs/unreleased/217570-improve-performance-for-blame-api.yml b/changelogs/unreleased/217570-improve-performance-for-blame-api.yml new file mode 100644 index 00000000000..390e19dc333 --- /dev/null +++ b/changelogs/unreleased/217570-improve-performance-for-blame-api.yml @@ -0,0 +1,5 @@ +--- +title: Lazy load commit_date and authored_date on Commit +merge_request: 34181 +author: +type: performance diff --git a/changelogs/unreleased/217816-add-evidence-to-releases-graphql-endpoint.yml b/changelogs/unreleased/217816-add-evidence-to-releases-graphql-endpoint.yml new file mode 100644 index 00000000000..79ffa011f63 --- /dev/null +++ b/changelogs/unreleased/217816-add-evidence-to-releases-graphql-endpoint.yml @@ -0,0 +1,5 @@ +--- +title: Add Evidence to Releases GraphQL endpoint +merge_request: 33254 +author: +type: added diff --git a/changelogs/unreleased/220954-replace-fa-file-image-o-with-gitlab-media-icon.yml b/changelogs/unreleased/220954-replace-fa-file-image-o-with-gitlab-media-icon.yml new file mode 100644 index 00000000000..9f01694f06a --- /dev/null +++ b/changelogs/unreleased/220954-replace-fa-file-image-o-with-gitlab-media-icon.yml @@ -0,0 +1,5 @@ +--- +title: Use GitLab SVG icon for file attacher action +merge_request: 34196 +author: +type: other diff --git a/changelogs/unreleased/Remove-addMilestone-logic-from-issue-models.yml b/changelogs/unreleased/Remove-addMilestone-logic-from-issue-models.yml new file mode 100644 index 00000000000..7c537b04d84 --- /dev/null +++ b/changelogs/unreleased/Remove-addMilestone-logic-from-issue-models.yml @@ -0,0 +1,5 @@ +--- +title: Remove addMilestone logic from issue model +merge_request: 32235 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Remove-findAssignee-logic-from-issues-model.yml b/changelogs/unreleased/Remove-findAssignee-logic-from-issues-model.yml new file mode 100644 index 00000000000..7e4bfe78cf6 --- /dev/null +++ b/changelogs/unreleased/Remove-findAssignee-logic-from-issues-model.yml @@ -0,0 +1,5 @@ +--- +title: Remove findAssignee logic from issue model +merge_request: 32238 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Remove-removeLabel-logic-from-issues-model.yml b/changelogs/unreleased/Remove-removeLabel-logic-from-issues-model.yml new file mode 100644 index 00000000000..a04939f68f4 --- /dev/null +++ b/changelogs/unreleased/Remove-removeLabel-logic-from-issues-model.yml @@ -0,0 +1,5 @@ +--- +title: Remove removeLabel logic from issue model +merge_request: 32251 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Remove-removeLabels-logic-from-issues-model.yml b/changelogs/unreleased/Remove-removeLabels-logic-from-issues-model.yml new file mode 100644 index 00000000000..18398999113 --- /dev/null +++ b/changelogs/unreleased/Remove-removeLabels-logic-from-issues-model.yml @@ -0,0 +1,5 @@ +--- +title: Remove removeLabels logic from issue model +merge_request: 32252 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Remove-removeMultipleIssues-logic-from-list-model.yml b/changelogs/unreleased/Remove-removeMultipleIssues-logic-from-list-model.yml new file mode 100644 index 00000000000..ba525900849 --- /dev/null +++ b/changelogs/unreleased/Remove-removeMultipleIssues-logic-from-list-model.yml @@ -0,0 +1,5 @@ +--- +title: Remove removeMultipleIssues logic from list model +merge_request: 32254 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/al-217784-add-blobs-field-to-snippets-in-graphql.yml b/changelogs/unreleased/al-217784-add-blobs-field-to-snippets-in-graphql.yml new file mode 100644 index 00000000000..89a3d978487 --- /dev/null +++ b/changelogs/unreleased/al-217784-add-blobs-field-to-snippets-in-graphql.yml @@ -0,0 +1,5 @@ +--- +title: Add blobs field to SnippetType in GraphQL +merge_request: 33657 +author: +type: changed diff --git a/changelogs/unreleased/fix_default_path_when_creating_project_from_group_template.yml b/changelogs/unreleased/fix_default_path_when_creating_project_from_group_template.yml new file mode 100644 index 00000000000..500b688f13f --- /dev/null +++ b/changelogs/unreleased/fix_default_path_when_creating_project_from_group_template.yml @@ -0,0 +1,5 @@ +--- +title: Fix default path when creating project from group template +merge_request: 30597 +author: Lee Tickett +type: fixed diff --git a/changelogs/unreleased/long-path-mr-bug.yml b/changelogs/unreleased/long-path-mr-bug.yml new file mode 100644 index 00000000000..ab171f3da5d --- /dev/null +++ b/changelogs/unreleased/long-path-mr-bug.yml @@ -0,0 +1,5 @@ +--- +title: Fix rendering of very long paths in merge request file tree +merge_request: 34153 +author: +type: fixed diff --git a/config/application.rb b/config/application.rb index 0752b6d15a3..b4fc89051f3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -22,7 +22,6 @@ module Gitlab require_dependency Rails.root.join('lib/gitlab/middleware/basic_health_check') require_dependency Rails.root.join('lib/gitlab/middleware/same_site_cookies') require_dependency Rails.root.join('lib/gitlab/middleware/handle_ip_spoof_attack_error') - require_dependency Rails.root.join('lib/gitlab/runtime') # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers diff --git a/config/environments/development.rb b/config/environments/development.rb index 25d57467060..9d4fc6ba5e9 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -49,8 +49,6 @@ Rails.application.configure do # Do not log asset requests config.assets.quiet = true - config.allow_concurrency = Gitlab::Runtime.multi_threaded? - # BetterErrors live shell (REPL) on every stack frame BetterErrors::Middleware.allow_ip!("127.0.0.1/0") diff --git a/config/environments/production.rb b/config/environments/production.rb index c03421040a3..393a274606e 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -77,6 +77,4 @@ Rails.application.configure do config.action_mailer.raise_delivery_errors = true config.eager_load = true - - config.allow_concurrency = Gitlab::Runtime.multi_threaded? end diff --git a/db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb b/db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb index 2db270d303c..76b00796d1a 100644 --- a/db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb +++ b/db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb @@ -17,7 +17,7 @@ class BackfillDeploymentClustersFromDeployments < ActiveRecord::Migration[6.0] class Deployment < ActiveRecord::Base include EachBatch - default_scope { where('cluster_id IS NOT NULL') } + default_scope { where('cluster_id IS NOT NULL') } # rubocop:disable Cop/DefaultScope self.table_name = 'deployments' end diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md index 3372d05bd6b..cd25fbf351b 100644 --- a/doc/administration/integration/plantuml.md +++ b/doc/administration/integration/plantuml.md @@ -105,6 +105,21 @@ To activate the changes, run the following command: sudo gitlab-ctl reconfigure ``` +### Security + +PlantUML has features that allows fetching network resources. + +```plaintext +@startuml +start + ' ... + !include http://localhost/ +stop; +@enduml +``` + +**If you self-host the PlantUML server, network controls should be put in place to isolate it.** + ## GitLab You need to enable PlantUML integration from Settings under Admin Area. To do diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md index 79afc5ac745..29f3a8d1942 100644 --- a/doc/administration/job_artifacts.md +++ b/doc/administration/job_artifacts.md @@ -351,7 +351,7 @@ you can flip the feature flag from a Rails console. 1. Flip the switch and disable it: ```ruby - Feature.enable('ci_disable_validates_dependencies') + Feature.enable(:ci_disable_validates_dependencies) ``` **In installations from source:** @@ -366,7 +366,7 @@ you can flip the feature flag from a Rails console. 1. Flip the switch and disable it: ```ruby - Feature.enable('ci_disable_validates_dependencies') + Feature.enable(:ci_disable_validates_dependencies) ``` ## Set the maximum file size of the artifacts diff --git a/doc/administration/job_logs.md b/doc/administration/job_logs.md index cc922653e80..d3484536a76 100644 --- a/doc/administration/job_logs.md +++ b/doc/administration/job_logs.md @@ -128,13 +128,13 @@ sudo -u git -H bin/rails console -e production **To check if incremental logging (trace) is enabled:** ```ruby -Feature.enabled?('ci_enable_live_trace') +Feature.enabled?(:ci_enable_live_trace) ``` **To enable incremental logging (trace):** ```ruby -Feature.enable('ci_enable_live_trace') +Feature.enable(:ci_enable_live_trace) ``` NOTE: **Note:** diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index b0cc72965aa..a4f4e366428 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -9972,6 +9972,31 @@ type Release { descriptionHtml: String """ + Evidence for the release + """ + evidences( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): ReleaseEvidenceConnection + + """ Milestones associated to the release """ milestones( @@ -10109,6 +10134,66 @@ type ReleaseEdge { node: Release } +""" +Evidence for a release +""" +type ReleaseEvidence { + """ + Timestamp when the evidence was collected + """ + collectedAt: Time + + """ + URL from where the evidence can be downloaded + """ + filepath: String + + """ + ID of the evidence + """ + id: ID! + + """ + SHA1 ID of the evidence hash + """ + sha: String +} + +""" +The connection type for ReleaseEvidence. +""" +type ReleaseEvidenceConnection { + """ + A list of edges. + """ + edges: [ReleaseEvidenceEdge] + + """ + A list of nodes. + """ + nodes: [ReleaseEvidence] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type ReleaseEvidenceEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: ReleaseEvidence +} + type ReleaseLink { """ Indicates the link points to an external resource @@ -11163,6 +11248,11 @@ type Snippet implements Noteable { blob: SnippetBlob! """ + Snippet blobs + """ + blobs: [SnippetBlob!]! + + """ Timestamp this snippet was created """ createdAt: Time! diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 10b34f6af91..c599ead2576 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -29244,6 +29244,59 @@ "deprecationReason": null }, { + "name": "evidences", + "description": "Evidence for the release", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ReleaseEvidenceConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "milestones", "description": "Milestones associated to the release", "args": [ @@ -29611,6 +29664,191 @@ }, { "kind": "OBJECT", + "name": "ReleaseEvidence", + "description": "Evidence for a release", + "fields": [ + { + "name": "collectedAt", + "description": "Timestamp when the evidence was collected", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filepath", + "description": "URL from where the evidence can be downloaded", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the evidence", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sha", + "description": "SHA1 ID of the evidence hash", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ReleaseEvidenceConnection", + "description": "The connection type for ReleaseEvidence.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ReleaseEvidenceEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ReleaseEvidence", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ReleaseEvidenceEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "ReleaseEvidence", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "ReleaseLink", "description": null, "fields": [ @@ -32975,6 +33213,32 @@ "deprecationReason": null }, { + "name": "blobs", + "description": "Snippet blobs", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SnippetBlob", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "createdAt", "description": "Timestamp this snippet was created", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 96bac1d3549..c2ec2eee136 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1406,6 +1406,17 @@ Represents a Project Member | --- | ---- | ---------- | | `assetsCount` | Int | Number of assets of the release | +## ReleaseEvidence + +Evidence for a release + +| Name | Type | Description | +| --- | ---- | ---------- | +| `collectedAt` | Time | Timestamp when the evidence was collected | +| `filepath` | String | URL from where the evidence can be downloaded | +| `id` | ID! | ID of the evidence | +| `sha` | String | SHA1 ID of the evidence hash | + ## ReleaseLink | Name | Type | Description | @@ -1632,6 +1643,7 @@ Represents a snippet entry | --- | ---- | ---------- | | `author` | User! | The owner of the snippet | | `blob` | SnippetBlob! | Snippet blob | +| `blobs` | SnippetBlob! => Array | Snippet blobs | | `createdAt` | Time! | Timestamp this snippet was created | | `description` | String | Description of the snippet | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | diff --git a/doc/development/changelog.md b/doc/development/changelog.md index 3f77da94e4e..5a8e05f888c 100644 --- a/doc/development/changelog.md +++ b/doc/development/changelog.md @@ -6,8 +6,8 @@ file, as well as information and history about our changelog process. ## Overview Each bullet point, or **entry**, in our [`CHANGELOG.md`](https://gitlab.com/gitlab-org/gitlab/blob/master/CHANGELOG.md) file is -generated from a single data file in the [`changelogs/unreleased/`](https://gitlab.com/gitlab-org/gitlab-foss/tree/master/changelogs/) -(or corresponding EE) folder. The file is expected to be a [YAML](https://en.wikipedia.org/wiki/YAML) file in the +generated from a single data file in the [`changelogs/unreleased/`](https://gitlab.com/gitlab-org/gitlab/tree/master/changelogs/unreleased/). +The file is expected to be a [YAML](https://en.wikipedia.org/wiki/YAML) file in the following format: ```yaml diff --git a/doc/user/group/iterations/index.md b/doc/user/group/iterations/index.md new file mode 100644 index 00000000000..2704147dcdd --- /dev/null +++ b/doc/user/group/iterations/index.md @@ -0,0 +1,85 @@ +--- +type: reference +stage: Plan +group: Project Management +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 +--- + +# Iterations **(STARTER)** + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/214713) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.1. +> - It's deployed behind a feature flag, disabled by default. +> - It's disabled on GitLab.com. +> - It's able to be enabled or disabled per-group +> - It's not recommended for production use. +> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-iterations-core-only). **(CORE ONLY)** + +Iterations are a way to track issues over a period of time. This allows teams +to track velocity and volatility metrics. Iterations can be used with [milestones](../../project/milestones/index.md) +for tracking over different time periods. + +For example, you can use: + +- Milestones for Program Increments, which span 8-12 weeks. +- Iterations for Sprints, which span 2 weeks. + +In GitLab, iterations are similar to milestones, with a few differences: + +- Iterations are only available to groups. +- A group can only have one active iteration at a time. +- Iterations require both a start and an end date. +- Iteration date ranges cannot overlap. + +## View the iterations list + +To view the iterations list, in a group, go to **{issues}** **Issues > Iterations**. +From there you can create a new iteration or click an iteration to get a more detailed view. + +## Create an iteration + +NOTE: **Note:** +A permission level of [Developer or higher](../../permissions.md) is required to create iterations. + +To create an iteration: + +1. In a group, go to **{issues}** **Issues > Iterations**. +1. Click **New iteration**. +1. Enter the title, a description (optional), a start date, and a due date. +1. Click **Create iteration**. The iteration details page opens. + +### Enable Iterations **(CORE ONLY)** + +GitLab Iterations feature is under development and not ready for production use. +It is deployed behind a feature flag that is **disabled by default**. +[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) +can enable it for your instance. `:group_iterations` can be enabled or disabled per-group. + +To enable it: + +```ruby +# Instance-wide +Feature.enable(:group_iterations) +# or by group +Feature.enable(:group_iterations, Group.find(<group id>)) +``` + +To disable it: + +```ruby +# Instance-wide +Feature.disable(:group_iterations) +# or by group +Feature.disable(:group_iterations, Group.find(<group id>)) +``` + +<!-- ## Troubleshooting + +Include any troubleshooting steps that you can foresee. If you know beforehand what issues +one might have when setting this up, or when something is changed, or on upgrading, it's +important to describe those, too. Think of things that may go wrong and include them here. +This is important to minimize requests for support, and to avoid doc comments with +questions that you know someone might ask. + +Each scenario can be a third-level heading, e.g. `### Getting error message X`. +If you have none to add when creating a doc, leave this section in place +but commented out to help encourage others to add to it in the future. --> diff --git a/lib/api/entities/releases/evidence.rb b/lib/api/entities/releases/evidence.rb index 25b2bf6bf6f..01603a71dbf 100644 --- a/lib/api/entities/releases/evidence.rb +++ b/lib/api/entities/releases/evidence.rb @@ -6,7 +6,7 @@ module API class Evidence < Grape::Entity include ::API::Helpers::Presentable - expose :summary_sha, as: :sha + expose :sha expose :filepath expose :collected_at end diff --git a/lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb b/lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb index c652a5bb3fc..e750b8ca374 100644 --- a/lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb +++ b/lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb @@ -62,7 +62,7 @@ module Gitlab class PrometheusService < ActiveRecord::Base self.inheritance_column = :_type_disabled self.table_name = 'services' - default_scope { where(type: type) } + default_scope { where(type: type) } # rubocop:disable Cop/DefaultScope def self.type 'PrometheusService' diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb index 4e83826b249..f76aacc2d19 100644 --- a/lib/gitlab/ci/trace.rb +++ b/lib/gitlab/ci/trace.rb @@ -147,7 +147,7 @@ module Gitlab raise AlreadyArchivedError, 'Could not write to the archived trace' elsif current_path File.open(current_path, mode) - elsif Feature.enabled?('ci_enable_live_trace', job.project) + elsif Feature.enabled?(:ci_enable_live_trace, job.project) Gitlab::Ci::Trace::ChunkedIO.new(job) else File.open(ensure_path, mode) diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index a554dc0b667..17d0a62ba8c 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -7,6 +7,7 @@ module Gitlab include Gitlab::EncodingHelper prepend Gitlab::Git::RuggedImpl::Commit extend Gitlab::Git::WrapsGitalyErrors + include Gitlab::Utils::StrongMemoize attr_accessor :raw_commit, :head @@ -231,6 +232,18 @@ module Gitlab parent_ids.first end + def committed_date + strong_memoize(:committed_date) do + init_date_from_gitaly(raw_commit.committer) if raw_commit + end + end + + def authored_date + strong_memoize(:authored_date) do + init_date_from_gitaly(raw_commit.author) if raw_commit + end + end + # Returns a diff object for the changes from this commit's first parent. # If there is no parent, then the diff is between this commit and an # empty repo. See Repository#diff for keys allowed in the +options+ @@ -369,11 +382,9 @@ module Gitlab # subject from the message to make it clearer when there's one # available but not the other. @message = message_from_gitaly_body - @authored_date = init_date_from_gitaly(commit.author) @author_name = commit.author.name.dup @author_email = commit.author.email.dup - @committed_date = init_date_from_gitaly(commit.committer) @committer_name = commit.committer.name.dup @committer_email = commit.committer.email.dup @parent_ids = Array(commit.parent_ids) diff --git a/lib/gitlab/prometheus_client.rb b/lib/gitlab/prometheus_client.rb index 9b626728171..213e3ba835d 100644 --- a/lib/gitlab/prometheus_client.rb +++ b/lib/gitlab/prometheus_client.rb @@ -71,15 +71,16 @@ module Gitlab end end - # Queries Prometheus for values aggregated by the given label string. + # Queries Prometheus with the given aggregate query and groups the results by mapping + # metric labels to their respective values. # # @return [Hash] mapping labels to their aggregate numeric values, or the empty hash if no results were found - def aggregate(func:, metric:, by:, time: Time.now) - response = query("#{func} (#{metric}) by (#{by})", time: time) + def aggregate(aggregate_query, time: Time.now) + response = query(aggregate_query, time: time) response.to_h do |result| - group_name = result.dig('metric', by) + key = block_given? ? yield(result['metric']) : result['metric'] _timestamp, value = result['value'] - [group_name, value.to_i] + [key, value.to_i] end end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 53a2020dd08..01c3712abe4 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -18,6 +18,7 @@ module Gitlab class << self include Gitlab::Utils::UsageData include Gitlab::Utils::StrongMemoize + include Gitlab::UsageDataConcerns::Topology def data(force_refresh: false) Rails.cache.fetch('usage_data', force: force_refresh, expires_in: 2.weeks) do @@ -247,25 +248,6 @@ module Gitlab } end - def topology_usage_data - topology_data, duration = measure_duration do - alt_usage_data(fallback: {}) do - { - nodes: topology_node_data - }.compact - end - end - { topology: topology_data.merge(duration_s: duration) } - end - - def topology_node_data - with_prometheus_client do |client| - by_instance_mem = - client.aggregate(func: 'avg', metric: 'node_memory_MemTotal_bytes', by: 'instance').compact - by_instance_mem.values.map { |v| { node_memory_total_bytes: v } } - end - end - def app_server_type Gitlab::Runtime.identify.to_s rescue Gitlab::Runtime::IdentificationError => e diff --git a/lib/gitlab/usage_data_concerns/topology.rb b/lib/gitlab/usage_data_concerns/topology.rb new file mode 100644 index 00000000000..ee1d9fb22a7 --- /dev/null +++ b/lib/gitlab/usage_data_concerns/topology.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Gitlab + module UsageDataConcerns + module Topology + include Gitlab::Utils::UsageData + + def topology_usage_data + topology_data, duration = measure_duration do + alt_usage_data(fallback: {}) do + { + nodes: topology_node_data + }.compact + end + end + { topology: topology_data.merge(duration_s: duration) } + end + + private + + def topology_node_data + with_prometheus_client do |client| + # node-level data + by_instance_mem = topology_node_memory(client) + by_instance_cpus = topology_node_cpus(client) + # service-level data + by_instance_by_job_by_metric_memory = topology_all_service_memory(client) + by_instance_by_job_process_count = topology_all_service_process_count(client) + + instances = Set.new(by_instance_mem.keys + by_instance_cpus.keys) + instances.map do |instance| + { + node_memory_total_bytes: by_instance_mem[instance], + node_cpus: by_instance_cpus[instance], + node_services: + topology_node_services(instance, by_instance_by_job_process_count, by_instance_by_job_by_metric_memory) + }.compact + end + end + end + + def topology_node_memory(client) + aggregate_single(client, 'avg (node_memory_MemTotal_bytes) by (instance)') + end + + def topology_node_cpus(client) + aggregate_single(client, 'count (node_cpu_seconds_total{mode="idle"}) by (instance)') + end + + def topology_all_service_memory(client) + aggregate_many( + client, + 'avg ({__name__=~"ruby_process_(resident|unique|proportional)_memory_bytes"}) by (instance, job, __name__)' + ) + end + + def topology_all_service_process_count(client) + aggregate_many(client, 'count (ruby_process_start_time_seconds) by (instance, job)') + end + + def topology_node_services(instance, all_process_counts, all_process_memory) + # returns all node service data grouped by service name as the key + instance_service_data = + topology_instance_service_process_count(instance, all_process_counts) + .deep_merge(topology_instance_service_memory(instance, all_process_memory)) + + # map to list of hashes where service name becomes a value instead + instance_service_data.map do |service, data| + { name: service.to_s }.merge(data) + end + end + + def topology_instance_service_process_count(instance, all_instance_data) + topology_data_for_instance(instance, all_instance_data).to_h do |metric, count| + job = metric['job'].underscore.to_sym + [job, { process_count: count }] + end + end + + def topology_instance_service_memory(instance, all_instance_data) + topology_data_for_instance(instance, all_instance_data).each_with_object({}) do |entry, hash| + metric, memory = entry + job = metric['job'].underscore.to_sym + key = + case metric['__name__'] + when 'ruby_process_resident_memory_bytes' then :process_memory_rss + when 'ruby_process_unique_memory_bytes' then :process_memory_uss + when 'ruby_process_proportional_memory_bytes' then :process_memory_pss + end + + hash[job] ||= {} + hash[job][key] ||= memory + end + end + + def topology_data_for_instance(instance, all_instance_data) + all_instance_data.filter { |metric, _value| metric['instance'] == instance } + end + + def drop_port(instance) + instance.gsub(/:.+$/, '') + end + + # Will retain a single `instance` key that values are mapped to + def aggregate_single(client, query) + client.aggregate(query) { |metric| drop_port(metric['instance']) } + end + + # Will retain a composite key that values are mapped to + def aggregate_many(client, query) + client.aggregate(query) do |metric| + metric['instance'] = drop_port(metric['instance']) + metric + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6a19d69ca6c..210f51a73db 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -9281,6 +9281,9 @@ msgstr "" msgid "Failed Jobs" msgstr "" +msgid "Failed on" +msgstr "" + msgid "Failed to add a Zoom meeting" msgstr "" @@ -14642,6 +14645,9 @@ msgstr "" msgid "New issue title" msgstr "" +msgid "New iteration" +msgstr "" + msgid "New iteration created" msgstr "" @@ -14819,6 +14825,9 @@ msgstr "" msgid "No grouping" msgstr "" +msgid "No iterations to show" +msgstr "" + msgid "No job log" msgstr "" @@ -15757,6 +15766,9 @@ msgstr "" msgid "Passed" msgstr "" +msgid "Passed on" +msgstr "" + msgid "Password" msgstr "" @@ -27246,6 +27258,9 @@ msgstr "" msgid "revised" msgstr "" +msgid "satisfied" +msgstr "" + msgid "score" msgstr "" diff --git a/rubocop/cop/default_scope.rb b/rubocop/cop/default_scope.rb new file mode 100644 index 00000000000..39f8c8e9ed0 --- /dev/null +++ b/rubocop/cop/default_scope.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + # Cop that blacklists the use of `default_scope`. + class DefaultScope < RuboCop::Cop::Cop + MSG = <<~EOF + Do not use `default_scope`, as it does not follow the principle of + least surprise. See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33847 + for more details. + EOF + + def_node_matcher :default_scope?, <<~PATTERN + (send {nil? (const nil? ...)} :default_scope ...) + PATTERN + + def on_send(node) + return unless default_scope?(node) + + add_offense(node, location: :expression) + end + end + end +end diff --git a/spec/factories/evidences.rb b/spec/factories/evidences.rb index 77116d8e9ed..dc9fc374103 100644 --- a/spec/factories/evidences.rb +++ b/spec/factories/evidences.rb @@ -3,5 +3,7 @@ FactoryBot.define do factory :evidence, class: 'Releases::Evidence' do release + summary_sha { "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d" } + summary { { "release": { "tag": "v4.0", "name": "New release", "project_name": "Project name" } } } end end diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb index 7e3c1728f3c..505e9b004fa 100644 --- a/spec/features/groups/container_registry_spec.rb +++ b/spec/features/groups/container_registry_spec.rb @@ -75,7 +75,7 @@ describe 'Container Registry', :js do expect(service).to receive(:execute).with(container_repository) { { status: :success } } expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service } - click_on(class: 'js-delete-registry') + first('[data-testid="singleDeleteButton"]').click expect(find('.modal .modal-title')).to have_content _('Remove tag') find('.modal .modal-footer .btn-danger').click end diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index fd5b4ec9345..1f608c3a337 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -46,6 +46,7 @@ describe 'Group navbar' do before do stub_feature_flags(group_push_rules: false) + stub_feature_flags(group_iterations: false) group.add_maintainer(user) sign_in(user) end diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb index 19f2bf8107b..3d78fd6885d 100644 --- a/spec/features/projects/container_registry_spec.rb +++ b/spec/features/projects/container_registry_spec.rb @@ -84,7 +84,7 @@ describe 'Container Registry', :js do expect(service).to receive(:execute).with(container_repository) { { status: :success } } expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['1']) { service } - first('.js-delete-registry').click + first('[data-testid="singleDeleteButton"]').click expect(find('.modal .modal-title')).to have_content _('Remove tag') find('.modal .modal-footer .btn-danger').click end diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js index c9d9d296543..847464ed806 100644 --- a/spec/frontend/ide/components/ide_status_list_spec.js +++ b/spec/frontend/ide/components/ide_status_list_spec.js @@ -5,10 +5,10 @@ import TerminalSyncStatusSafe from '~/ide/components/terminal_sync/terminal_sync const TEST_FILE = { name: 'lorem.md', - eol: 'LF', editorRow: 3, editorColumn: 23, fileLanguage: 'markdown', + content: 'abc\nndef', }; const localVue = createLocalVue(); @@ -56,7 +56,8 @@ describe('ide/components/ide_status_list', () => { }); it('shows file eol', () => { - expect(wrapper.text()).toContain(TEST_FILE.name); + expect(wrapper.text()).not.toContain('CRLF'); + expect(wrapper.text()).toContain('LF'); }); it('shows file editor position', () => { diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index a418fdeb572..ad27954cd10 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', - base64: false, binary: false, rawPath: '', }); @@ -103,7 +102,6 @@ describe('new dropdown upload', () => { name: binaryFile.name, type: 'blob', content: binaryTarget.result.split('base64,')[1], - base64: true, 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 28a08b0f346..614f62009ad 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -94,47 +94,24 @@ describe('RepoEditor', () => { }); describe('when file is markdown', () => { - beforeEach(done => { - vm.file.previewMode = { - id: 'markdown', - previewTitle: 'Preview Markdown', - }; - - vm.$nextTick(done); - }); - - it('renders an Edit and a Preview Tab', done => { - Vue.nextTick(() => { - const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); - - expect(tabs.length).toBe(2); - expect(tabs[0].textContent.trim()).toBe('Edit'); - expect(tabs[1].textContent.trim()).toBe('Preview Markdown'); - - done(); - }); - }); - }); - - describe('when file is markdown and viewer mode is review', () => { let mock; - beforeEach(done => { + beforeEach(() => { mock = new MockAdapter(axios); - - vm.file.projectId = 'namespace/project'; - vm.file.previewMode = { - id: 'markdown', - previewTitle: 'Preview Markdown', - }; - vm.file.content = 'testing 123'; - vm.$store.state.viewer = 'diff'; - mock.onPost(/(.*)\/preview_markdown/).reply(200, { body: '<p>testing 123</p>', }); - vm.$nextTick(done); + Vue.set(vm, 'file', { + ...vm.file, + projectId: 'namespace/project', + path: 'sample.md', + content: 'testing 123', + }); + + vm.$store.state.entries[vm.file.path] = vm.file; + + return vm.$nextTick(); }); afterEach(() => { @@ -146,7 +123,7 @@ describe('RepoEditor', () => { const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); expect(tabs.length).toBe(2); - expect(tabs[0].textContent.trim()).toBe('Review'); + expect(tabs[0].textContent.trim()).toBe('Edit'); expect(tabs[1].textContent.trim()).toBe('Preview Markdown'); done(); @@ -155,8 +132,6 @@ describe('RepoEditor', () => { it('renders markdown for tempFile', done => { vm.file.tempFile = true; - vm.file.path = `${vm.file.path}.md`; - vm.$store.state.entries[vm.file.path] = vm.file; vm.$nextTick() .then(() => { @@ -171,6 +146,20 @@ describe('RepoEditor', () => { .then(done) .catch(done.fail); }); + + describe('when not in edit mode', () => { + beforeEach(async () => { + await vm.$nextTick(); + + vm.$store.state.currentActivityView = leftSidebarViews.review.name; + + return vm.$nextTick(); + }); + + it('shows no tabs', () => { + expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0); + }); + }); }); describe('when open file is binary and not raw', () => { @@ -560,7 +549,6 @@ describe('RepoEditor', () => { path: 'foo/foo.png', type: 'blob', content: 'Zm9v', - base64: true, binary: true, rawPath: 'data:image/png;base64,Zm9v', }); diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js index 52870d7bc75..a14879112fd 100644 --- a/spec/frontend/ide/stores/modules/commit/actions_spec.js +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -13,8 +13,8 @@ import { commitActionTypes, PERMISSION_CREATE_MR } from '~/ide/constants'; import testAction from '../../../../helpers/vuex_action_helper'; jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), visitUrl: jest.fn(), - joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, })); const TEST_COMMIT_SHA = '123456789'; diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js index a032984ad43..d1eb4304c79 100644 --- a/spec/frontend/ide/stores/utils_spec.js +++ b/spec/frontend/ide/stores/utils_spec.js @@ -46,7 +46,7 @@ describe('Multi-file store utils', () => { path: 'added', tempFile: true, content: 'new file content', - base64: true, + rawPath: 'data:image/png;base64,abc', lastCommitSha: '123456789', }, { ...file('deletedFile'), path: 'deletedFile', deleted: true }, @@ -117,7 +117,7 @@ describe('Multi-file store utils', () => { path: 'added', tempFile: true, content: 'new file content', - base64: true, + rawPath: 'data:image/png;base64,abc', lastCommitSha: '123456789', }, ], diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index d63e793c43f..76e0e435860 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -192,6 +192,20 @@ describe('text_utility', () => { 'app/…/…/diff', ); }); + + describe('given a path too long for the maxWidth', () => { + it.each` + path | maxWidth | result + ${'aa/bb/cc'} | ${1} | ${'…'} + ${'aa/bb/cc'} | ${2} | ${'…'} + ${'aa/bb/cc'} | ${3} | ${'…/…'} + ${'aa/bb/cc'} | ${4} | ${'…/…'} + ${'aa/bb/cc'} | ${5} | ${'…/…/…'} + `('truncates ($path, $maxWidth) to $result', ({ path, maxWidth, result }) => { + expect(result.length).toBeLessThanOrEqual(maxWidth); + expect(textUtils.truncatePathMiddleToLength(path, maxWidth)).toEqual(result); + }); + }); }); describe('slugifyWithUnderscore', () => { diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index c494033badd..85e680fe216 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -371,6 +371,23 @@ describe('URL utility', () => { }); }); + describe('isBase64DataUrl', () => { + it.each` + url | valid + ${undefined} | ${false} + ${'http://gitlab.com'} | ${false} + ${'data:image/png;base64,abcdef'} | ${true} + ${'data:application/smil+xml;base64,abcdef'} | ${true} + ${'data:application/vnd.syncml+xml;base64,abcdef'} | ${true} + ${'data:application/vnd.3m.post-it-notes;base64,abcdef'} | ${true} + ${'notaurl'} | ${false} + ${'../relative_url'} | ${false} + ${'<a></a>'} | ${false} + `('returns $valid for $url', ({ url, valid }) => { + expect(urlUtils.isBase64DataUrl(url)).toBe(valid); + }); + }); + describe('relativePathToAbsolute', () => { it.each` path | base | result diff --git a/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap b/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap new file mode 100644 index 00000000000..aeb49f88770 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TagsLoader component has the correct markup 1`] = ` +<div> + <div + preserve-aspect-ratio="xMinYMax meet" + > + <rect + height="15" + rx="4" + width="15" + x="0" + y="12.5" + /> + + <rect + height="20" + rx="4" + width="250" + x="25" + y="10" + /> + + <circle + cx="290" + cy="20" + r="10" + /> + + <rect + height="20" + rx="4" + width="100" + x="315" + y="10" + /> + + <rect + height="20" + rx="4" + width="100" + x="500" + y="10" + /> + + <rect + height="20" + rx="4" + width="100" + x="630" + y="10" + /> + + <rect + height="40" + rx="4" + width="40" + x="960" + y="0" + /> + </div> +</div> +`; diff --git a/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js index 6ef4dcf96b4..5d54986978b 100644 --- a/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js @@ -19,6 +19,11 @@ describe('Delete alert', () => { wrapper = shallowMount(component, { stubs: { GlSprintf }, propsData }); }; + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + describe('when deleteAlertType is null', () => { it('does not show the alert', () => { mountComponent(); diff --git a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js index b30818005c3..c77f7a54d34 100644 --- a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js @@ -23,6 +23,11 @@ describe('Delete Modal', () => { }); }; + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + it('contains a GlModal', () => { mountComponent(); expect(findModal().exists()).toBe(true); diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index ad2b8a12ecc..cb31efa428f 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -15,6 +15,11 @@ describe('Details Header', () => { }); }; + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + it('has the correct title ', () => { mountComponent(); expect(wrapper.text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE); diff --git a/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js b/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js new file mode 100644 index 00000000000..da80c75a26a --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js @@ -0,0 +1,43 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import component from '~/registry/explorer/components/details_page/empty_tags_state.vue'; +import { + EMPTY_IMAGE_REPOSITORY_TITLE, + EMPTY_IMAGE_REPOSITORY_MESSAGE, +} from '~/registry/explorer/constants'; + +describe('EmptyTagsState component', () => { + let wrapper; + + const findEmptyState = () => wrapper.find(GlEmptyState); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + GlEmptyState, + }, + propsData: { + noContainersImage: 'foo', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('contains gl-empty-state', () => { + mountComponent(); + expect(findEmptyState().exist()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + expect(findEmptyState().props()).toMatchObject({ + title: EMPTY_IMAGE_REPOSITORY_TITLE, + description: EMPTY_IMAGE_REPOSITORY_MESSAGE, + svgPath: 'foo', + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js new file mode 100644 index 00000000000..b27d3e2c042 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/registry/explorer/components/details_page/tags_loader.vue'; +import { GlSkeletonLoader } from '../../stubs'; + +describe('TagsLoader component', () => { + let wrapper; + + const findGlSkeletonLoaders = () => wrapper.findAll(GlSkeletonLoader); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + GlSkeletonLoader, + }, + // set the repeat to 1 to avoid a long and verbose snapshot + loader: { + ...component.loader, + repeat: 1, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('produces the correct amount of loaders ', () => { + mountComponent(); + expect(findGlSkeletonLoaders().length).toBe(1); + }); + + it('has the correct props', () => { + mountComponent(); + expect( + findGlSkeletonLoaders() + .at(0) + .props(), + ).toMatchObject({ + width: component.loader.width, + height: component.loader.height, + }); + }); + + it('has the correct markup', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js new file mode 100644 index 00000000000..85cd2f7ebb5 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js @@ -0,0 +1,287 @@ +import { mount } from '@vue/test-utils'; +import stubChildren from 'helpers/stub_children'; +import component from '~/registry/explorer/components/details_page/tags_table.vue'; +import { tagsListResponse } from '../../mock_data'; + +describe('tags_table', () => { + let wrapper; + const tags = [...tagsListResponse.data]; + + const findMainCheckbox = () => wrapper.find('[data-testid="mainCheckbox"]'); + const findFirstRowItem = testid => wrapper.find(`[data-testid="${testid}"]`); + const findBulkDeleteButton = () => wrapper.find('[data-testid="bulkDeleteButton"]'); + const findAllDeleteButtons = () => wrapper.findAll('[data-testid="singleDeleteButton"]'); + const findAllCheckboxes = () => wrapper.findAll('[data-testid="rowCheckbox"]'); + const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked')); + const findFirsTagColumn = () => wrapper.find('.js-tag-column'); + const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]'); + + const findLoaderSlot = () => wrapper.find('[data-testid="loaderSlot"]'); + const findEmptySlot = () => wrapper.find('[data-testid="emptySlot"]'); + + const mountComponent = (propsData = { tags, isDesktop: true }) => { + wrapper = mount(component, { + stubs: { + ...stubChildren(component), + GlTable: false, + }, + propsData, + slots: { + loader: '<div data-testid="loaderSlot"></div>', + empty: '<div data-testid="emptySlot"></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each([ + 'rowCheckbox', + 'rowName', + 'rowShortRevision', + 'rowSize', + 'rowTime', + 'singleDeleteButton', + ])('%s exist in the table', element => { + mountComponent(); + + expect(findFirstRowItem(element).exists()).toBe(true); + }); + + describe('header checkbox', () => { + it('exists', () => { + mountComponent(); + expect(findMainCheckbox().exists()).toBe(true); + }); + + it('if selected selects all the rows', () => { + mountComponent(); + findMainCheckbox().vm.$emit('change'); + return wrapper.vm.$nextTick().then(() => { + expect(findMainCheckbox().attributes('checked')).toBeTruthy(); + expect(findCheckedCheckboxes()).toHaveLength(tags.length); + }); + }); + + it('if deselect deselects all the row', () => { + mountComponent(); + findMainCheckbox().vm.$emit('change'); + return wrapper.vm + .$nextTick() + .then(() => { + expect(findMainCheckbox().attributes('checked')).toBeTruthy(); + findMainCheckbox().vm.$emit('change'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findMainCheckbox().attributes('checked')).toBe(undefined); + expect(findCheckedCheckboxes()).toHaveLength(0); + }); + }); + }); + + describe('row checkbox', () => { + beforeEach(() => { + mountComponent(); + }); + + it('if selected adds item to selectedItems', () => { + findFirstRowItem('rowCheckbox').vm.$emit('change'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.selectedItems).toEqual([tags[0].name]); + expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy(); + }); + }); + + it('if deselect remove name from selectedItems', () => { + wrapper.setData({ selectedItems: [tags[0].name] }); + findFirstRowItem('rowCheckbox').vm.$emit('change'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.selectedItems.length).toBe(0); + expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBe(undefined); + }); + }); + }); + + describe('header delete button', () => { + beforeEach(() => { + mountComponent(); + }); + + it('exists', () => { + expect(findBulkDeleteButton().exists()).toBe(true); + }); + + it('is disabled if no item is selected', () => { + expect(findBulkDeleteButton().attributes('disabled')).toBe('true'); + }); + + it('is enabled if at least one item is selected', () => { + expect(findBulkDeleteButton().attributes('disabled')).toBe('true'); + findFirstRowItem('rowCheckbox').vm.$emit('change'); + return wrapper.vm.$nextTick().then(() => { + expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy(); + }); + }); + + describe('on click', () => { + it('when one item is selected', () => { + findFirstRowItem('rowCheckbox').vm.$emit('change'); + findBulkDeleteButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[['centos6']]]); + }); + + it('when multiple items are selected', () => { + findMainCheckbox().vm.$emit('change'); + findBulkDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[tags.map(t => t.name)]]); + }); + }); + }); + + describe('row delete button', () => { + beforeEach(() => { + mountComponent(); + }); + + it('exists', () => { + expect( + findAllDeleteButtons() + .at(0) + .exists(), + ).toBe(true); + }); + + it('is disabled if the item has no destroy_path', () => { + expect( + findAllDeleteButtons() + .at(1) + .attributes('disabled'), + ).toBe('true'); + }); + + it('on click', () => { + findAllDeleteButtons() + .at(0) + .vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[['centos6']]]); + }); + }); + + describe('name cell', () => { + it('tag column has a tooltip with the tag name', () => { + mountComponent(); + expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name); + }); + + describe('on desktop viewport', () => { + beforeEach(() => { + mountComponent(); + }); + + it('table header has class w-25', () => { + expect(findFirsTagColumn().classes()).toContain('w-25'); + }); + + it('tag column has the mw-m class', () => { + expect(findFirstRowItem('rowName').classes()).toContain('mw-m'); + }); + }); + + describe('on mobile viewport', () => { + beforeEach(() => { + mountComponent({ tags, isDesktop: false }); + }); + + it('table header does not have class w-25', () => { + expect(findFirsTagColumn().classes()).not.toContain('w-25'); + }); + + it('tag column has the gl-justify-content-end class', () => { + expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end'); + }); + }); + }); + + describe('last updated cell', () => { + let timeCell; + + beforeEach(() => { + mountComponent(); + timeCell = findFirstRowItem('rowTime'); + }); + + it('displays the time in string format', () => { + expect(timeCell.text()).toBe('2 years ago'); + }); + + it('has a tooltip timestamp', () => { + expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000'); + }); + }); + + describe('empty state slot', () => { + describe('when the table is empty', () => { + beforeEach(() => { + mountComponent({ tags: [], isDesktop: true }); + }); + + it('does not show table rows', () => { + expect(findFirstTagNameText().exists()).toBe(false); + }); + + it('has the empty state slot', () => { + expect(findEmptySlot().exists()).toBe(true); + }); + }); + + describe('when the table is not empty', () => { + beforeEach(() => { + mountComponent({ tags, isDesktop: true }); + }); + + it('does show table rows', () => { + expect(findFirstTagNameText().exists()).toBe(true); + }); + + it('does not show the empty state', () => { + expect(findEmptySlot().exists()).toBe(false); + }); + }); + }); + + describe('loader slot', () => { + describe('when the data is loading', () => { + beforeEach(() => { + mountComponent({ isLoading: true, tags }); + }); + + it('show the loader', () => { + expect(findLoaderSlot().exists()).toBe(true); + }); + + it('does not show the table rows', () => { + expect(findFirstTagNameText().exists()).toBe(false); + }); + }); + + describe('when the data is not loading', () => { + beforeEach(() => { + mountComponent({ isLoading: false, tags }); + }); + + it('does not show the loader', () => { + expect(findLoaderSlot().exists()).toBe(false); + }); + + it('shows the table rows', () => { + expect(findFirstTagNameText().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js index 5786bb1f869..5ce570fb65b 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js @@ -24,6 +24,11 @@ describe('Image List', () => { mountComponent(); }); + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + describe('list', () => { it('contains one list element for each image', () => { expect(findRow().length).toBe(imagesListResponse.data.length); diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 6fa4448083a..316962c72be 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -1,11 +1,11 @@ -import { mount } from '@vue/test-utils'; -import { GlTable, GlPagination, GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { GlPagination } from '@gitlab/ui'; import Tracking from '~/tracking'; -import stubChildren from 'helpers/stub_children'; import component from '~/registry/explorer/pages/details.vue'; import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue'; -import DeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue'; import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue'; +import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; +import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue'; import { createStore } from '~/registry/explorer/stores/'; import { SET_MAIN_LOADING, @@ -15,7 +15,7 @@ import { } from '~/registry/explorer/stores/mutation_types/'; import { tagsListResponse } from '../mock_data'; -import { $toast } from '../../shared/mocks'; +import { TagsTable, DeleteModal } from '../stubs'; describe('Details Page', () => { let wrapper; @@ -24,28 +24,19 @@ describe('Details Page', () => { const findDeleteModal = () => wrapper.find(DeleteModal); const findPagination = () => wrapper.find(GlPagination); - const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); - const findMainCheckbox = () => wrapper.find({ ref: 'mainCheckbox' }); - const findFirstRowItem = ref => wrapper.find({ ref }); - const findBulkDeleteButton = () => wrapper.find({ ref: 'bulkDeleteButton' }); - // findAll and refs seems to no work falling back to class - const findAllDeleteButtons = () => wrapper.findAll('.js-delete-registry'); - const findAllCheckboxes = () => wrapper.findAll('.js-row-checkbox'); - const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked')); - const findFirsTagColumn = () => wrapper.find('.js-tag-column'); - const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]'); + const findTagsLoader = () => wrapper.find(TagsLoader); + const findTagsTable = () => wrapper.find(TagsTable); const findDeleteAlert = () => wrapper.find(DeleteAlert); const findDetailsHeader = () => wrapper.find(DetailsHeader); + const findEmptyTagsState = () => wrapper.find(EmptyTagsState); const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' })); const mountComponent = options => { - wrapper = mount(component, { + wrapper = shallowMount(component, { store, stubs: { - ...stubChildren(component), - GlSprintf: false, - GlTable, + TagsTable, DeleteModal, }, mocks: { @@ -54,7 +45,6 @@ describe('Details Page', () => { id: routeId, }, }, - $toast, }, ...options, }); @@ -67,7 +57,6 @@ describe('Details Page', () => { store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data); store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers); jest.spyOn(Tracking, 'event'); - jest.spyOn(DeleteModal.methods, 'show'); }); afterEach(() => { @@ -78,18 +67,14 @@ describe('Details Page', () => { describe('when isLoading is true', () => { beforeEach(() => { mountComponent(); - store.dispatch('receiveTagsListSuccess', { ...tagsListResponse, data: [] }); store.commit(SET_MAIN_LOADING, true); + return wrapper.vm.$nextTick(); }); - afterAll(() => store.commit(SET_MAIN_LOADING, false)); + afterEach(() => store.commit(SET_MAIN_LOADING, false)); - it('has a skeleton loader', () => { - expect(findSkeletonLoader().exists()).toBe(true); - }); - - it('does not have list items', () => { - expect(findFirstRowItem('rowCheckbox').exists()).toBe(false); + it('binds isLoading to tags-table', () => { + expect(findTagsTable().props('isLoading')).toBe(true); }); it('does not show pagination', () => { @@ -97,204 +82,76 @@ describe('Details Page', () => { }); }); - describe('table', () => { - it.each([ - 'rowCheckbox', - 'rowName', - 'rowShortRevision', - 'rowSize', - 'rowTime', - 'singleDeleteButton', - ])('%s exist in the table', element => { + describe('table slots', () => { + beforeEach(() => { mountComponent(); - expect(findFirstRowItem(element).exists()).toBe(true); }); - describe('header checkbox', () => { - beforeEach(() => { - mountComponent(); - }); - - it('exists', () => { - expect(findMainCheckbox().exists()).toBe(true); - }); - - it('if selected set selectedItem and allSelected', () => { - findMainCheckbox().vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(findMainCheckbox().attributes('checked')).toBeTruthy(); - expect(findCheckedCheckboxes()).toHaveLength(store.state.tags.length); - }); - }); - - it('if deselect unset selectedItem and allSelected', () => { - wrapper.setData({ selectedItems: [1, 2], selectAllChecked: true }); - findMainCheckbox().vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(findMainCheckbox().attributes('checked')).toBe(undefined); - expect(findCheckedCheckboxes()).toHaveLength(0); - }); - }); + it('has the empty state', () => { + expect(findEmptyTagsState().exists()).toBe(true); }); - describe('row checkbox', () => { - beforeEach(() => { - mountComponent(); - }); - - it('if selected adds item to selectedItems', () => { - findFirstRowItem('rowCheckbox').vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.selectedItems).toEqual([store.state.tags[1].name]); - expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy(); - }); - }); - - it('if deselect remove name from selectedItems', () => { - wrapper.setData({ selectedItems: [store.state.tags[1].name] }); - findFirstRowItem('rowCheckbox').vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.selectedItems.length).toBe(0); - expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBe(undefined); - }); - }); + it('has a skeleton loader', () => { + expect(findTagsLoader().exists()).toBe(true); }); + }); - describe('header delete button', () => { - beforeEach(() => { - mountComponent(); - }); + describe('table', () => { + beforeEach(() => { + mountComponent(); + }); - it('exists', () => { - mountComponent(); - expect(findBulkDeleteButton().exists()).toBe(true); - }); + it('exists', () => { + expect(findTagsTable().exists()).toBe(true); + }); - it('is disabled if no item is selected', () => { - mountComponent(); - expect(findBulkDeleteButton().attributes('disabled')).toBe('true'); + it('has the correct props bound', () => { + expect(findTagsTable().props()).toMatchObject({ + isDesktop: true, + isLoading: false, + tags: store.state.tags, }); + }); - it('is enabled if at least one item is selected', () => { - mountComponent({ data: () => ({ selectedItems: [store.state.tags[0].name] }) }); - wrapper.setData({ selectedItems: [1] }); - return wrapper.vm.$nextTick().then(() => { - expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy(); + describe('deleteEvent', () => { + describe('single item', () => { + beforeEach(() => { + findTagsTable().vm.$emit('delete', [store.state.tags[0].name]); }); - }); - describe('on click', () => { - it('when one item is selected', () => { - mountComponent({ data: () => ({ selectedItems: [store.state.tags[0].name] }) }); - jest.spyOn(wrapper.vm.$refs.deleteModal, 'show'); - findBulkDeleteButton().vm.$emit('click'); - expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]); + it('open the modal', () => { expect(DeleteModal.methods.show).toHaveBeenCalled(); - expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { - label: 'registry_tag_delete', - }); }); - it('when multiple items are selected', () => { - mountComponent({ - data: () => ({ selectedItems: store.state.tags.map(t => t.name) }), - }); - findBulkDeleteButton().vm.$emit('click'); + it('maps the selection to itemToBeDeleted', () => { + expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]); + }); - expect(wrapper.vm.itemsToBeDeleted).toEqual(tagsListResponse.data); - expect(DeleteModal.methods.show).toHaveBeenCalled(); + it('tracks a single delete event', () => { expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { - label: 'bulk_registry_tag_delete', + label: 'registry_tag_delete', }); }); }); - }); - - describe('row delete button', () => { - beforeEach(() => { - mountComponent(); - }); - it('exists', () => { - expect( - findAllDeleteButtons() - .at(0) - .exists(), - ).toBe(true); - }); - - it('is disabled if the item has no destroy_path', () => { - expect( - findAllDeleteButtons() - .at(1) - .attributes('disabled'), - ).toBe('true'); - }); - - it('on click', () => { - findAllDeleteButtons() - .at(0) - .vm.$emit('click'); - - expect(DeleteModal.methods.show).toHaveBeenCalled(); - expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { - label: 'registry_tag_delete', - }); - }); - }); - - describe('name cell', () => { - it('tag column has a tooltip with the tag name', () => { - mountComponent(); - expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name); - }); - - describe('on desktop viewport', () => { + describe('multiple items', () => { beforeEach(() => { - mountComponent(); + findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name)); }); - it('table header has class w-25', () => { - expect(findFirsTagColumn().classes()).toContain('w-25'); + it('open the modal', () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); }); - it('tag column has the mw-m class', () => { - expect(findFirstRowItem('rowName').classes()).toContain('mw-m'); + it('maps the selection to itemToBeDeleted', () => { + expect(wrapper.vm.itemsToBeDeleted).toEqual(store.state.tags); }); - }); - describe('on mobile viewport', () => { - beforeEach(() => { - mountComponent({ - data() { - return { isDesktop: false }; - }, + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'bulk_registry_tag_delete', }); }); - - it('table header does not have class w-25', () => { - expect(findFirsTagColumn().classes()).not.toContain('w-25'); - }); - - it('tag column has the gl-justify-content-end class', () => { - expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end'); - }); - }); - }); - - describe('last updated cell', () => { - let timeCell; - - beforeEach(() => { - mountComponent(); - timeCell = findFirstRowItem('rowTime'); - }); - - it('displays the time in string format', () => { - expect(timeCell.text()).toBe('2 years ago'); - }); - it('has a tooltip timestamp', () => { - expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000'); }); }); }); @@ -343,44 +200,33 @@ describe('Details Page', () => { describe('confirmDelete event', () => { describe('when one item is selected to be deleted', () => { - const itemsToBeDeleted = [{ name: 'foo' }]; + beforeEach(() => { + mountComponent(); + findTagsTable().vm.$emit('delete', [store.state.tags[0].name]); + }); it('dispatch requestDeleteTag with the right parameters', () => { - mountComponent({ data: () => ({ itemsToBeDeleted }) }); findDeleteModal().vm.$emit('confirmDelete'); expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', { - tag: itemsToBeDeleted[0], + tag: store.state.tags[0], params: routeId, }); }); - it('remove the deleted item from the selected items', () => { - mountComponent({ data: () => ({ itemsToBeDeleted, selectedItems: ['foo', 'bar'] }) }); - findDeleteModal().vm.$emit('confirmDelete'); - expect(wrapper.vm.selectedItems).toEqual(['bar']); - }); }); describe('when more than one item is selected to be deleted', () => { beforeEach(() => { - mountComponent({ - data: () => ({ - itemsToBeDeleted: [{ name: 'foo' }, { name: 'bar' }], - selectedItems: ['foo', 'bar'], - }), - }); + mountComponent(); + findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name)); }); it('dispatch requestDeleteTags with the right parameters', () => { findDeleteModal().vm.$emit('confirmDelete'); expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', { - ids: ['foo', 'bar'], + ids: store.state.tags.map(t => t.name), params: routeId, }); }); - it('clears the selectedItems', () => { - findDeleteModal().vm.$emit('confirmDelete'); - expect(wrapper.vm.selectedItems).toEqual([]); - }); }); }); }); diff --git a/spec/frontend/registry/explorer/stores/getters_spec.js b/spec/frontend/registry/explorer/stores/getters_spec.js index cd053ea8edc..4cab65d2bb0 100644 --- a/spec/frontend/registry/explorer/stores/getters_spec.js +++ b/spec/frontend/registry/explorer/stores/getters_spec.js @@ -2,35 +2,6 @@ import * as getters from '~/registry/explorer/stores/getters'; describe('Getters RegistryExplorer store', () => { let state; - const tags = ['foo', 'bar']; - - describe('tags', () => { - describe('when isLoading is false', () => { - beforeEach(() => { - state = { - tags, - isLoading: false, - }; - }); - - it('returns tags', () => { - expect(getters.tags(state)).toEqual(state.tags); - }); - }); - - describe('when isLoading is true', () => { - beforeEach(() => { - state = { - tags, - isLoading: true, - }; - }); - - it('returns empty array', () => { - expect(getters.tags(state)).toEqual([]); - }); - }); - }); describe.each` getter | prefix | configParameter | suffix diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js index 0e178abfbed..d3518c36c82 100644 --- a/spec/frontend/registry/explorer/stubs.js +++ b/spec/frontend/registry/explorer/stubs.js @@ -1,3 +1,6 @@ +import RealTagsTable from '~/registry/explorer/components/details_page/tags_table.vue'; +import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue'; + export const GlModal = { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', methods: { @@ -14,3 +17,21 @@ export const RouterLink = { template: `<div><slot></slot></div>`, props: ['to'], }; + +export const TagsTable = { + props: RealTagsTable.props, + template: `<div><slot name="empty"></slot><slot name="loader"></slot></div>`, +}; + +export const DeleteModal = { + template: '<div></div>', + methods: { + show: jest.fn(), + }, + props: RealDeleteModal.props, +}; + +export const GlSkeletonLoader = { + template: `<div><slot></slot></div>`, + props: ['width', 'height'], +}; diff --git a/spec/graphql/types/evidence_type_spec.rb b/spec/graphql/types/evidence_type_spec.rb new file mode 100644 index 00000000000..4a11f7bcda9 --- /dev/null +++ b/spec/graphql/types/evidence_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['ReleaseEvidence'] do + it { expect(described_class).to require_graphql_authorizations(:download_code) } + + it 'has the expected fields' do + expected_fields = %w[ + id sha filepath collected_at + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/release_type_spec.rb b/spec/graphql/types/release_type_spec.rb index 402ad3a7447..feafe5ed519 100644 --- a/spec/graphql/types/release_type_spec.rb +++ b/spec/graphql/types/release_type_spec.rb @@ -9,7 +9,7 @@ describe GitlabSchema.types['Release'] do expected_fields = %w[ tag_name tag_path description description_html - name assets milestones author commit + name assets milestones evidences author commit created_at released_at ] @@ -28,6 +28,12 @@ describe GitlabSchema.types['Release'] do it { is_expected.to have_graphql_type(Types::MilestoneType.connection_type) } end + describe 'evidences field' do + subject { described_class.fields['evidences'] } + + it { is_expected.to have_graphql_type(Types::EvidenceType.connection_type) } + end + describe 'author field' do subject { described_class.fields['author'] } diff --git a/spec/graphql/types/snippet_type_spec.rb b/spec/graphql/types/snippet_type_spec.rb index adc13d4d651..3c26a05986f 100644 --- a/spec/graphql/types/snippet_type_spec.rb +++ b/spec/graphql/types/snippet_type_spec.rb @@ -11,7 +11,7 @@ describe GitlabSchema.types['Snippet'] do :visibility_level, :created_at, :updated_at, :web_url, :raw_url, :ssh_url_to_repo, :http_url_to_repo, :notes, :discussions, :user_permissions, - :description_html, :blob] + :description_html, :blob, :blobs] expect(described_class).to have_graphql_fields(*expected_fields) end @@ -76,30 +76,14 @@ describe GitlabSchema.types['Snippet'] do describe '#blob' do let(:query_blob) { subject.dig('data', 'snippets', 'edges')[0]['node']['blob'] } - let(:query) do - %( - { - snippets { - edges { - node { - blob { - name - path - } - } - } - } - } - ) - end - subject { GitlabSchema.execute(query, context: { current_user: user }).as_json } + subject { GitlabSchema.execute(snippet_query_for(field: 'blob'), context: { current_user: user }).as_json } context 'when snippet has repository' do let!(:snippet) { create(:personal_snippet, :repository, :public, author: user) } let(:blob) { snippet.blobs.first } - it 'returns blob from the repository' do + it 'returns the first blob from the repository' do expect(query_blob['name']).to eq blob.name expect(query_blob['path']).to eq blob.path end @@ -115,4 +99,58 @@ describe GitlabSchema.types['Snippet'] do end end end + + describe '#blobs' do + let_it_be(:snippet) { create(:personal_snippet, :public, author: user) } + let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0]['node']['blobs'] } + + subject { GitlabSchema.execute(snippet_query_for(field: 'blobs'), context: { current_user: user }).as_json } + + shared_examples 'an array' do + it 'returns an array of snippet blobs' do + expect(query_blobs).to be_an(Array) + end + end + + context 'when snippet does not have a repository' do + let(:blob) { snippet.blob } + + it_behaves_like 'an array' + + it 'contains the first blob from the snippet' do + expect(query_blobs.first['name']).to eq blob.name + expect(query_blobs.first['path']).to eq blob.path + end + end + + context 'when snippet has repository' do + let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) } + let(:blobs) { snippet.blobs } + + it_behaves_like 'an array' + + it 'contains all the blobs from the repository' do + resulting_blobs_names = query_blobs.map { |b| b['name'] } + + expect(resulting_blobs_names).to match_array(blobs.map(&:name)) + end + end + end + + def snippet_query_for(field:) + %( + { + snippets { + edges { + node { + #{field} { + name + path + } + } + } + } + } + ) + end end diff --git a/spec/lib/gitlab/prometheus_client_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb index 5a8da215788..749192e5795 100644 --- a/spec/lib/gitlab/prometheus_client_spec.rb +++ b/spec/lib/gitlab/prometheus_client_spec.rb @@ -172,8 +172,7 @@ describe Gitlab::PrometheusClient do end describe '#aggregate' do - let(:user_query) { { func: 'avg', metric: 'metric', by: 'job' } } - let(:prometheus_query) { 'avg (metric) by (job)' } + let(:query) { 'avg (metric) by (job)' } let(:prometheus_response) do { "status": "success", @@ -192,19 +191,19 @@ describe Gitlab::PrometheusClient do } } end - let(:query_url) { prometheus_query_with_time_url(prometheus_query, Time.now.utc) } + let(:query_url) { prometheus_query_with_time_url(query, Time.now.utc) } around do |example| Timecop.freeze { example.run } end context 'when request returns vector results' do - it 'returns data from the API call' do + it 'returns data from the API call grouped by labels' do req_stub = stub_prometheus_request(query_url, body: prometheus_response) - expect(subject.aggregate(user_query)).to eq({ - "gitlab-rails" => 1, - "gitlab-sidekiq" => 2 + expect(subject.aggregate(query)).to eq({ + { "job" => "gitlab-rails" } => 1, + { "job" => "gitlab-sidekiq" } => 2 }) expect(req_stub).to have_been_requested end @@ -214,13 +213,13 @@ describe Gitlab::PrometheusClient do it 'returns {}' do req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('vector')) - expect(subject.aggregate(user_query)).to eq({}) + expect(subject.aggregate(query)).to eq({}) expect(req_stub).to have_been_requested end end it_behaves_like 'failure response' do - let(:execute_query) { subject.aggregate(user_query) } + let(:execute_query) { subject.aggregate(query) } end end diff --git a/spec/lib/gitlab/usage_data_concerns/topology_spec.rb b/spec/lib/gitlab/usage_data_concerns/topology_spec.rb new file mode 100644 index 00000000000..b9eed7a6192 --- /dev/null +++ b/spec/lib/gitlab/usage_data_concerns/topology_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::UsageDataConcerns::Topology do + include UsageDataHelpers + + describe '#topology_usage_data' do + subject { Class.new.extend(described_class).topology_usage_data } + + before do + # this pins down time shifts when benchmarking durations + allow(Process).to receive(:clock_gettime).and_return(0) + end + + context 'when embedded Prometheus server is enabled' do + before do + expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(true) + expect(Gitlab::Prometheus::Internal).to receive(:uri).and_return('http://prom:9090') + end + + it 'contains a topology element' do + allow_prometheus_queries + + expect(subject).to have_key(:topology) + end + + context 'tracking node metrics' do + it 'contains node level metrics for each instance' do + expect_prometheus_api_to( + receive_node_memory_query, + receive_node_cpu_count_query, + receive_node_service_memory_query, + receive_node_service_process_count_query + ) + + expect(subject[:topology]).to eq({ + duration_s: 0, + nodes: [ + { + node_memory_total_bytes: 512, + node_cpus: 8, + node_services: [ + { + name: 'gitlab_rails', + process_count: 10, + process_memory_rss: 300, + process_memory_uss: 301, + process_memory_pss: 302 + }, + { + name: 'gitlab_sidekiq', + process_count: 5, + process_memory_rss: 303 + } + ] + }, + { + node_memory_total_bytes: 1024, + node_cpus: 16, + node_services: [ + { + name: 'gitlab_sidekiq', + process_count: 15, + process_memory_rss: 400, + process_memory_pss: 401 + } + ] + } + ] + }) + end + end + + context 'and some node memory metrics are missing' do + it 'removes the respective entries' do + expect_prometheus_api_to( + receive_node_memory_query(result: []), + receive_node_cpu_count_query, + receive_node_service_memory_query, + receive_node_service_process_count_query + ) + + keys = subject[:topology][:nodes].flat_map(&:keys) + expect(keys).not_to include(:node_memory_total_bytes) + expect(keys).to include(:node_cpus, :node_services) + end + end + + context 'and no results are found' do + it 'does not report anything' do + expect_prometheus_api_to receive(:aggregate).at_least(:once).and_return({}) + + expect(subject[:topology]).to eq({ + duration_s: 0, + nodes: [] + }) + end + end + + context 'and a connection error is raised' do + it 'does not report anything' do + expect_prometheus_api_to receive(:aggregate).and_raise('Connection failed') + + expect(subject[:topology]).to eq({ duration_s: 0 }) + end + end + end + + context 'when embedded Prometheus server is disabled' do + it 'does not report anything' do + expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(false) + + expect(subject[:topology]).to eq({ duration_s: 0 }) + end + end + end + + def receive_node_memory_query(result: nil) + receive(:query) + .with('avg (node_memory_MemTotal_bytes) by (instance)', an_instance_of(Hash)) + .and_return(result || [ + { + 'metric' => { 'instance' => 'instance1:8080' }, + 'value' => [1000, '512'] + }, + { + 'metric' => { 'instance' => 'instance2:8090' }, + 'value' => [1000, '1024'] + } + ]) + end + + def receive_node_cpu_count_query(result: nil) + receive(:query) + .with('count (node_cpu_seconds_total{mode="idle"}) by (instance)', an_instance_of(Hash)) + .and_return(result || [ + { + 'metric' => { 'instance' => 'instance2:8090' }, + 'value' => [1000, '16'] + }, + { + 'metric' => { 'instance' => 'instance1:8080' }, + 'value' => [1000, '8'] + } + ]) + end + + def receive_node_service_memory_query(result: nil) + receive(:query) + .with('avg ({__name__=~"ruby_process_(resident|unique|proportional)_memory_bytes"}) by (instance, job, __name__)', an_instance_of(Hash)) + .and_return(result || [ + # instance 1: runs Puma + a small Sidekiq + { + 'metric' => { 'instance' => 'instance1:8080', 'job' => 'gitlab-rails', '__name__' => 'ruby_process_resident_memory_bytes' }, + 'value' => [1000, '300'] + }, + { + 'metric' => { 'instance' => 'instance1:8080', 'job' => 'gitlab-rails', '__name__' => 'ruby_process_unique_memory_bytes' }, + 'value' => [1000, '301'] + }, + { + 'metric' => { 'instance' => 'instance1:8080', 'job' => 'gitlab-rails', '__name__' => 'ruby_process_proportional_memory_bytes' }, + 'value' => [1000, '302'] + }, + { + 'metric' => { 'instance' => 'instance1:8090', 'job' => 'gitlab-sidekiq', '__name__' => 'ruby_process_resident_memory_bytes' }, + 'value' => [1000, '303'] + }, + # instance 2: runs a dedicated Sidekiq + { + 'metric' => { 'instance' => 'instance2:8090', 'job' => 'gitlab-sidekiq', '__name__' => 'ruby_process_resident_memory_bytes' }, + 'value' => [1000, '400'] + }, + { + 'metric' => { 'instance' => 'instance2:8090', 'job' => 'gitlab-sidekiq', '__name__' => 'ruby_process_proportional_memory_bytes' }, + 'value' => [1000, '401'] + } + ]) + end + + def receive_node_service_process_count_query(result: nil) + receive(:query) + .with('count (ruby_process_start_time_seconds) by (instance, job)', an_instance_of(Hash)) + .and_return(result || [ + # instance 1 + { + 'metric' => { 'instance' => 'instance1:8080', 'job' => 'gitlab-rails' }, + 'value' => [1000, '10'] + }, + { + 'metric' => { 'instance' => 'instance1:8090', 'job' => 'gitlab-sidekiq' }, + 'value' => [1000, '5'] + }, + # instance 2 + { + 'metric' => { 'instance' => 'instance2:8090', 'job' => 'gitlab-sidekiq' }, + 'value' => [1000, '15'] + } + ]) + end +end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index bc77b2e2bb5..d23739ee096 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -115,6 +115,10 @@ describe Gitlab::UsageData, :aggregate_failures do ) end + it 'gathers topology data' do + expect(subject.keys).to include(:topology) + end + context 'with existing container expiration policies' do let_it_be(:disabled) { create(:container_expiration_policy, enabled: false) } let_it_be(:enabled) { create(:container_expiration_policy, enabled: true) } @@ -278,88 +282,6 @@ describe Gitlab::UsageData, :aggregate_failures do end end - describe '#topology_usage_data' do - subject { described_class.topology_usage_data } - - before do - # this pins down time shifts when benchmarking durations - allow(Process).to receive(:clock_gettime).and_return(0) - end - - context 'when embedded Prometheus server is enabled' do - before do - expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(true) - expect(Gitlab::Prometheus::Internal).to receive(:uri).and_return('http://prom:9090') - end - - it 'contains a topology element' do - allow_prometheus_queries - - expect(subject).to have_key(:topology) - end - - context 'tracking node metrics' do - it 'contains node level metrics for each instance' do - expect_prometheus_api_to receive(:aggregate) - .with(func: 'avg', metric: 'node_memory_MemTotal_bytes', by: 'instance') - .and_return({ - 'instance1' => 512, - 'instance2' => 1024 - }) - - expect(subject[:topology]).to eq({ - duration_s: 0, - nodes: [ - { - node_memory_total_bytes: 512 - }, - { - node_memory_total_bytes: 1024 - } - ] - }) - end - end - - context 'and no results are found' do - it 'does not report anything' do - expect_prometheus_api_to receive(:aggregate).and_return({}) - - expect(subject[:topology]).to eq({ - duration_s: 0, - nodes: [] - }) - end - end - - context 'and a connection error is raised' do - it 'does not report anything' do - expect_prometheus_api_to receive(:aggregate).and_raise('Connection failed') - - expect(subject[:topology]).to eq({ duration_s: 0 }) - end - end - end - - context 'when embedded Prometheus server is disabled' do - it 'does not report anything' do - expect(subject[:topology]).to eq({ duration_s: 0 }) - end - end - - def expect_prometheus_api_to(receive_matcher) - expect_next_instance_of(Gitlab::PrometheusClient) do |client| - expect(client).to receive_matcher - end - end - - def allow_prometheus_queries - allow_next_instance_of(Gitlab::PrometheusClient) do |client| - allow(client).to receive(:aggregate).and_return({}) - end - end - end - describe '#app_server_type' do subject { described_class.app_server_type } diff --git a/spec/models/project_metrics_setting_spec.rb b/spec/models/project_metrics_setting_spec.rb index 7df01625ba1..c6f668a11bc 100644 --- a/spec/models/project_metrics_setting_spec.rb +++ b/spec/models/project_metrics_setting_spec.rb @@ -44,12 +44,12 @@ describe ProjectMetricsSetting do it { is_expected.to be_valid } end - context 'external_dashboard_url is blank' do - before do - subject.external_dashboard_url = '' - end + context 'dashboard_timezone' do + it { is_expected.to define_enum_for(:dashboard_timezone).with_values({ local: 0, utc: 1 }) } - it { is_expected.to be_invalid } + it 'defaults to local' do + expect(subject.dashboard_timezone).to eq('local') + end end end end diff --git a/spec/presenters/snippet_presenter_spec.rb b/spec/presenters/snippet_presenter_spec.rb index 591d86652b6..423e9edc219 100644 --- a/spec/presenters/snippet_presenter_spec.rb +++ b/spec/presenters/snippet_presenter_spec.rb @@ -163,4 +163,25 @@ describe SnippetPresenter do end end end + + describe '#blobs' do + let(:snippet) { personal_snippet } + + subject { presenter.blobs } + + context 'when snippet does not have a repository' do + it 'returns an array with one SnippetBlob' do + expect(subject.size).to eq(1) + expect(subject.first).to eq(snippet.blob) + end + end + + context 'when snippet has a repository' do + let(:snippet) { create(:snippet, :repository, author: user) } + + it 'returns an array with all repository blobs' do + expect(subject).to match_array(snippet.blobs) + end + end + end end diff --git a/spec/requests/api/graphql/project/release_spec.rb b/spec/requests/api/graphql/project/release_spec.rb index f580c69cd44..f8624a97a2b 100644 --- a/spec/requests/api/graphql/project/release_spec.rb +++ b/spec/requests/api/graphql/project/release_spec.rb @@ -5,11 +5,12 @@ require 'pp' describe 'Query.project(fullPath).release(tagName)' do include GraphqlHelpers + include Presentable let_it_be(:project) { create(:project, :repository) } let_it_be(:milestone_1) { create(:milestone, project: project) } let_it_be(:milestone_2) { create(:milestone, project: project) } - let_it_be(:release) { create(:release, project: project, milestones: [milestone_1, milestone_2]) } + let_it_be(:release) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2]) } let_it_be(:release_link_1) { create(:release_link, release: release) } let_it_be(:release_link_2) { create(:release_link, release: release) } let_it_be(:developer) { create(:user) } @@ -164,5 +165,42 @@ describe 'Query.project(fullPath).release(tagName)' do expect(data).to match_array(expected) end end + + describe 'evidences' do + let(:path) { path_prefix + %w[evidences] } + let(:release_fields) do + query_graphql_field(:evidences, nil, 'nodes { id sha filepath collectedAt }') + end + + context 'for a developer' do + it 'finds all evidence fields' do + post_query + + evidence = release.evidences.first.present + expected = { + 'id' => global_id_of(evidence), + 'sha' => evidence.sha, + 'filepath' => evidence.filepath, + 'collectedAt' => evidence.collected_at.utc.iso8601 + } + + expect(data["nodes"].first).to eq(expected) + end + end + + context 'for a guest' do + let(:current_user) { create :user } + + before do + project.add_guest(current_user) + end + + it 'denies access' do + post_query + + expect(data['node']).to be_nil + end + end + end end end diff --git a/spec/rubocop/cop/default_scope_spec.rb b/spec/rubocop/cop/default_scope_spec.rb new file mode 100644 index 00000000000..9520915f900 --- /dev/null +++ b/spec/rubocop/cop/default_scope_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rubocop' +require 'rubocop/rspec/support' +require_relative '../../../rubocop/cop/default_scope' + +describe RuboCop::Cop::DefaultScope do + include CopHelper + + subject(:cop) { described_class.new } + + it 'does not flag the use of default_scope with a send receiver' do + inspect_source('foo.default_scope') + + expect(cop.offenses.size).to eq(0) + end + + it 'flags the use of default_scope with a constant receiver' do + inspect_source('User.default_scope') + + expect(cop.offenses.size).to eq(1) + end + + it 'flags the use of default_scope with a nil receiver' do + inspect_source('class Foo ; default_scope ; end') + + expect(cop.offenses.size).to eq(1) + end + + it 'flags the use of default_scope when passing arguments' do + inspect_source('class Foo ; default_scope(:foo) ; end') + + expect(cop.offenses.size).to eq(1) + end + + it 'flags the use of default_scope when passing a block' do + inspect_source('class Foo ; default_scope { :foo } ; end') + + expect(cop.offenses.size).to eq(1) + end + + it 'ignores the use of default_scope with a local variable receiver' do + inspect_source('users = User.all ; users.default_scope') + + expect(cop.offenses.size).to eq(0) + end +end diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb index e5842fb9085..f4d62b48fe5 100644 --- a/spec/services/projects/operations/update_service_spec.rb +++ b/spec/services/projects/operations/update_service_spec.rb @@ -126,21 +126,23 @@ describe Projects::Operations::UpdateService do ) expect(project.metrics_setting.dashboard_timezone).to eq('utc') end + end - context 'with blank external_dashboard_url in params' do - let(:params) do - { - metrics_setting_attributes: { - external_dashboard_url: '' - } + context 'with blank external_dashboard_url' do + let(:params) do + { + metrics_setting_attributes: { + external_dashboard_url: '', + dashboard_timezone: 'utc' } - end + } + end - it 'destroys the metrics_setting entry in DB' do - expect(result[:status]).to eq(:success) + it 'updates dashboard_timezone' do + expect(result[:status]).to eq(:success) - expect(project.reload.metrics_setting).to be_nil - end + expect(project.reload.metrics_setting.external_dashboard_url).to be(nil) + expect(project.metrics_setting.dashboard_timezone).to eq('utc') end end end diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index 15e4c93def7..40779204fd3 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -220,4 +220,16 @@ module UsageDataHelpers 'proxy_download' => false } } ) end + + def expect_prometheus_api_to(*receive_matchers) + expect_next_instance_of(Gitlab::PrometheusClient) do |client| + receive_matchers.each { |m| expect(client).to m } + end + end + + def allow_prometheus_queries + allow_next_instance_of(Gitlab::PrometheusClient) do |client| + allow(client).to receive(:aggregate).and_return({}) + end + end end |