diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-03-31 18:07:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-03-31 18:07:42 +0000 |
commit | 580622bdb3c762a8e89facd8a3946881ee480442 (patch) | |
tree | 3ac9d759da23f78f95f50684bd238a9f76839538 | |
parent | b211a4ea14d5e9ed9b0c248a4e8c5c1d85b542cb (diff) | |
download | gitlab-ce-580622bdb3c762a8e89facd8a3946881ee480442.tar.gz |
Add latest changes from gitlab-org/gitlab@master
73 files changed, 1049 insertions, 3124 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 197596031b8..d11761e0607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ entry. ## 12.9.2 (2020-03-31) -- No changes. ### Fixed (5 changes) - Ensure import by URL works after a failed import. !27546 diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index 0739b4d5e39..179a515e5ca 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -3,6 +3,9 @@ import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import Cookies from 'js-cookie'; import { glEmojiTag } from '~/emoji'; +import Tracking from '~/tracking'; + +const trackingMixin = Tracking.mixin(); export default { beginnerLink: @@ -23,6 +26,7 @@ export default { GlSprintf, GlLink, }, + mixins: [trackingMixin], props: { goToPipelinesPath: { type: String, @@ -32,8 +36,21 @@ export default { type: String, required: true, }, + humanAccess: { + type: String, + required: true, + }, + }, + data() { + return { + tracking: { + label: 'congratulate_first_pipeline', + property: this.humanAccess, + }, + }; }, mounted() { + this.track(); this.disableModalFromRenderingAgain(); }, methods: { diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js index 47fea2be189..cdafe838994 100644 --- a/app/assets/javascripts/pages/groups/registry/repositories/index.js +++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js @@ -1,9 +1,10 @@ -import initRegistryImages from '~/registry/list/index'; import registryExplorer from '~/registry/explorer/index'; document.addEventListener('DOMContentLoaded', () => { - initRegistryImages(); - const { attachMainComponent, attachBreadcrumb } = registryExplorer(); - attachBreadcrumb(); - attachMainComponent(); + const explorer = registryExplorer(); + + if (explorer) { + explorer.attachBreadcrumb(); + explorer.attachMainComponent(); + } }); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 4d308d6b07a..9ff68a88f95 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -45,12 +45,13 @@ document.addEventListener('DOMContentLoaded', () => { new Vue({ el: successPipelineEl, render(createElement) { - const { commitCookie, pipelinesPath: goToPipelinesPath } = this.$el.dataset; + const { commitCookie, goToPipelinesPath, humanAccess } = this.$el.dataset; return createElement(PipelineTourSuccessModal, { props: { goToPipelinesPath, commitCookie, + humanAccess, }, }); }, diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js index 73469e287ed..cdafe838994 100644 --- a/app/assets/javascripts/pages/projects/registry/repositories/index.js +++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js @@ -1,9 +1,6 @@ -import initRegistryImages from '~/registry/list/index'; import registryExplorer from '~/registry/explorer/index'; document.addEventListener('DOMContentLoaded', () => { - initRegistryImages(); - const explorer = registryExplorer(); if (explorer) { diff --git a/app/assets/javascripts/registry/list/components/app.vue b/app/assets/javascripts/registry/list/components/app.vue deleted file mode 100644 index c555c2b04d1..00000000000 --- a/app/assets/javascripts/registry/list/components/app.vue +++ /dev/null @@ -1,153 +0,0 @@ -<script> -import { mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; -import store from '../stores'; -import CollapsibleContainer from './collapsible_container.vue'; -import ProjectEmptyState from './project_empty_state.vue'; -import GroupEmptyState from './group_empty_state.vue'; -import { s__, sprintf } from '~/locale'; - -export default { - name: 'RegistryListApp', - components: { - CollapsibleContainer, - GlEmptyState, - GlLoadingIcon, - ProjectEmptyState, - GroupEmptyState, - }, - props: { - characterError: { - type: Boolean, - required: false, - default: false, - }, - containersErrorImage: { - type: String, - required: true, - }, - endpoint: { - type: String, - required: true, - }, - helpPagePath: { - type: String, - required: true, - }, - noContainersImage: { - type: String, - required: true, - }, - personalAccessTokensHelpLink: { - type: String, - required: false, - default: null, - }, - registryHostUrlWithPort: { - type: String, - required: false, - default: null, - }, - repositoryUrl: { - type: String, - required: true, - }, - isGroupPage: { - type: Boolean, - default: false, - required: false, - }, - twoFactorAuthHelpLink: { - type: String, - required: false, - default: null, - }, - }, - store, - computed: { - ...mapGetters(['isLoading', 'repos']), - dockerConnectionErrorText() { - return sprintf( - s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an - issue with your project name or path. - %{docLinkStart}More Information%{docLinkEnd}`), - { - docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`, - docLinkEnd: '</a>', - }, - false, - ); - }, - introText() { - return sprintf( - s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every - project can have its own space to store its Docker images. - %{docLinkStart}More Information%{docLinkEnd}`), - { - docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, - docLinkEnd: '</a>', - }, - false, - ); - }, - noContainerImagesText() { - return sprintf( - s__(`ContainerRegistry|With the Container Registry, every project can have its own space to - store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`), - { - docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, - docLinkEnd: '</a>', - }, - false, - ); - }, - }, - created() { - this.setMainEndpoint(this.endpoint); - this.setIsDeleteDisabled(this.isGroupPage); - }, - mounted() { - if (!this.characterError) { - this.fetchRepos(); - } - }, - methods: { - ...mapActions(['setMainEndpoint', 'fetchRepos', 'setIsDeleteDisabled']), - }, -}; -</script> -<template> - <div> - <gl-empty-state - v-if="characterError" - :title="s__('ContainerRegistry|Docker connection error')" - :svg-path="containersErrorImage" - > - <template #description> - <p class="js-character-error-text" v-html="dockerConnectionErrorText"></p> - </template> - </gl-empty-state> - - <gl-loading-icon v-else-if="isLoading" size="md" class="prepend-top-16" /> - - <div v-else-if="!isLoading && repos.length"> - <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4> - <p v-html="introText"></p> - <collapsible-container v-for="item in repos" :key="item.id" :repo="item" /> - </div> - <project-empty-state - v-else-if="!isGroupPage" - :no-containers-image="noContainersImage" - :help-page-path="helpPagePath" - :repository-url="repositoryUrl" - :two-factor-auth-help-link="twoFactorAuthHelpLink" - :personal-access-tokens-help-link="personalAccessTokensHelpLink" - :registry-host-url-with-port="registryHostUrlWithPort" - /> - <group-empty-state - v-else-if="isGroupPage" - :no-containers-image="noContainersImage" - :help-page-path="helpPagePath" - /> - </div> -</template> diff --git a/app/assets/javascripts/registry/list/components/collapsible_container.vue b/app/assets/javascripts/registry/list/components/collapsible_container.vue deleted file mode 100644 index 9786a1a3f75..00000000000 --- a/app/assets/javascripts/registry/list/components/collapsible_container.vue +++ /dev/null @@ -1,155 +0,0 @@ -<script> -import { mapActions, mapGetters } from 'vuex'; -import { - GlLoadingIcon, - GlButton, - GlTooltipDirective, - GlModal, - GlModalDirective, - GlEmptyState, -} from '@gitlab/ui'; -import createFlash from '~/flash'; -import Tracking from '~/tracking'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import Icon from '~/vue_shared/components/icon.vue'; -import TableRegistry from './table_registry.vue'; -import { DELETE_REPO_ERROR_MESSAGE } from '../constants'; -import { __, sprintf } from '~/locale'; - -export default { - name: 'CollapsibeContainerRegisty', - components: { - ClipboardButton, - TableRegistry, - GlLoadingIcon, - GlButton, - Icon, - GlModal, - GlEmptyState, - }, - directives: { - GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, - }, - mixins: [Tracking.mixin()], - props: { - repo: { - type: Object, - required: true, - }, - }, - data() { - return { - isOpen: false, - modalId: `confirm-repo-deletion-modal-${this.repo.id}`, - tracking: { - label: 'registry_repository_delete', - }, - }; - }, - computed: { - ...mapGetters(['isDeleteDisabled']), - iconName() { - return this.isOpen ? 'angle-up' : 'angle-right'; - }, - canDeleteRepo() { - return this.repo.canDelete && !this.isDeleteDisabled; - }, - deleteImageConfirmationMessage() { - return sprintf(__('Image %{imageName} was scheduled for deletion from the registry.'), { - imageName: this.repo.name, - }); - }, - }, - methods: { - ...mapActions(['fetchRepos', 'fetchList', 'deleteItem']), - toggleRepo() { - this.isOpen = !this.isOpen; - - if (this.isOpen) { - this.fetchList({ repo: this.repo }); - } - }, - handleDeleteRepository() { - this.track('confirm_delete'); - return this.deleteItem(this.repo) - .then(() => { - createFlash(this.deleteImageConfirmationMessage, 'notice'); - this.fetchRepos(); - }) - .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE)); - }, - }, -}; -</script> - -<template> - <div class="container-image"> - <div class="container-image-head"> - <gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo"> - <icon :name="iconName" /> - {{ repo.name }} - </gl-button> - - <clipboard-button - v-if="repo.location" - :text="repo.location" - :title="repo.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - - <div class="controls d-none d-sm-block float-right"> - <gl-button - v-if="canDeleteRepo" - v-gl-tooltip - v-gl-modal="modalId" - :title="s__('ContainerRegistry|Remove repository')" - :aria-label="s__('ContainerRegistry|Remove repository')" - class="js-remove-repo btn-inverted" - variant="danger" - @click="track('click_button')" - > - <icon name="remove" /> - </gl-button> - </div> - </div> - - <gl-loading-icon v-if="repo.isLoading" size="md" class="append-bottom-20" /> - - <div v-else-if="!repo.isLoading && isOpen" class="container-image-tags"> - <table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" /> - <gl-empty-state - v-else - :title="s__('ContainerRegistry|This image has no active tags')" - :description=" - s__( - `ContainerRegistry|The last tag related to this image was recently removed. - This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. - If you have any questions, contact your administrator.`, - ) - " - class="mx-auto my-0" - /> - </div> - <gl-modal - ref="deleteModal" - :modal-id="modalId" - ok-variant="danger" - @ok="handleDeleteRepository" - @cancel="track('cancel_delete')" - > - <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> - <p - v-html=" - sprintf( - s__( - 'ContainerRegistry|You are about to remove repository <b>%{title}</b>. Once you confirm, this repository will be permanently deleted.', - ), - { title: repo.name }, - ) - " - ></p> - <template v-slot:modal-ok>{{ __('Remove') }}</template> - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/registry/list/components/group_empty_state.vue b/app/assets/javascripts/registry/list/components/group_empty_state.vue deleted file mode 100644 index 7885fd2146d..00000000000 --- a/app/assets/javascripts/registry/list/components/group_empty_state.vue +++ /dev/null @@ -1,46 +0,0 @@ -<script> -import { GlEmptyState } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; - -export default { - name: 'GroupEmptyState', - components: { - GlEmptyState, - }, - props: { - noContainersImage: { - type: String, - required: true, - }, - helpPagePath: { - type: String, - required: true, - }, - }, - computed: { - noContainerImagesText() { - return sprintf( - s__( - `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`, - ), - { - docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, - docLinkEnd: '</a>', - }, - false, - ); - }, - }, -}; -</script> -<template> - <gl-empty-state - :title="s__('ContainerRegistry|There are no container images available in this group')" - :svg-path="noContainersImage" - class="container-message" - > - <template #description> - <p class="js-no-container-images-text" v-html="noContainerImagesText"></p> - </template> - </gl-empty-state> -</template> diff --git a/app/assets/javascripts/registry/list/components/project_empty_state.vue b/app/assets/javascripts/registry/list/components/project_empty_state.vue deleted file mode 100644 index 900498ed03d..00000000000 --- a/app/assets/javascripts/registry/list/components/project_empty_state.vue +++ /dev/null @@ -1,133 +0,0 @@ -<script> -import { GlEmptyState } from '@gitlab/ui'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { s__, sprintf } from '~/locale'; - -export default { - name: 'ProjectEmptyState', - components: { - ClipboardButton, - GlEmptyState, - }, - props: { - noContainersImage: { - type: String, - required: true, - }, - repositoryUrl: { - type: String, - required: true, - }, - helpPagePath: { - type: String, - required: true, - }, - twoFactorAuthHelpLink: { - type: String, - required: true, - }, - personalAccessTokensHelpLink: { - type: String, - required: true, - }, - registryHostUrlWithPort: { - type: String, - required: true, - }, - }, - computed: { - dockerBuildCommand() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `docker build -t ${this.repositoryUrl} .`; - }, - dockerPushCommand() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `docker push ${this.repositoryUrl}`; - }, - dockerLoginCommand() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `docker login ${this.registryHostUrlWithPort}`; - }, - noContainerImagesText() { - return sprintf( - s__(`ContainerRegistry|With the Container Registry, every project can have its own space to - store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`), - { - docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, - docLinkEnd: '</a>', - }, - false, - ); - }, - notLoggedInToRegistryText() { - return sprintf( - s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to - the Container Registry by using your GitLab username and password. If you have - %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a - %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} - instead of a password.`), - { - twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`, - twofaDocLinkEnd: '</a>', - personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`, - personalAccessTokensDocLinkEnd: '</a>', - }, - false, - ); - }, - }, -}; -</script> -<template> - <gl-empty-state - :title="s__('ContainerRegistry|There are no container images stored for this project')" - :svg-path="noContainersImage" - class="container-message" - > - <template #description> - <p class="js-no-container-images-text" v-html="noContainerImagesText"></p> - <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> - <p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p> - <div class="input-group append-bottom-10"> - <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> - <clipboard-button - :text="dockerLoginCommand" - :title="s__('ContainerRegistry|Copy login command')" - class="input-group-text" - /> - </span> - </div> - <p></p> - <p> - {{ - s__( - 'ContainerRegistry|You can add an image to this registry with the following commands:', - ) - }} - </p> - - <div class="input-group append-bottom-10"> - <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> - <clipboard-button - :text="dockerBuildCommand" - :title="s__('ContainerRegistry|Copy build command')" - class="input-group-text" - /> - </span> - </div> - - <div class="input-group"> - <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> - <clipboard-button - :text="dockerPushCommand" - :title="s__('ContainerRegistry|Copy push command')" - class="input-group-text" - /> - </span> - </div> - </template> - </gl-empty-state> -</template> diff --git a/app/assets/javascripts/registry/list/components/table_registry.vue b/app/assets/javascripts/registry/list/components/table_registry.vue deleted file mode 100644 index 4e14db7f578..00000000000 --- a/app/assets/javascripts/registry/list/components/table_registry.vue +++ /dev/null @@ -1,289 +0,0 @@ -<script> -import { mapActions, mapGetters } from 'vuex'; -import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui'; -import Tracking from '~/tracking'; -import { n__, s__, sprintf } from '~/locale'; -import createFlash from '~/flash'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import Icon from '~/vue_shared/components/icon.vue'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants'; - -export default { - components: { - ClipboardButton, - TablePagination, - GlFormCheckbox, - GlButton, - Icon, - GlModal, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin, Tracking.mixin()], - props: { - repo: { - type: Object, - required: true, - }, - canDeleteRepo: { - type: Boolean, - default: false, - required: false, - }, - }, - data() { - return { - selectedItems: [], - itemsToBeDeleted: [], - modalId: `confirm-image-deletion-modal-${this.repo.id}`, - selectAllChecked: false, - modalDescription: '', - }; - }, - computed: { - ...mapGetters(['isDeleteDisabled']), - bulkDeletePath() { - return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : ''; - }, - shouldRenderPagination() { - return this.repo.pagination.total > this.repo.pagination.perPage; - }, - modalAction() { - return n__( - 'ContainerRegistry|Remove tag', - 'ContainerRegistry|Remove tags', - this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, - ); - }, - isMultiDelete() { - return this.itemsToBeDeleted.length > 1; - }, - tracking() { - return { - property: this.repo.name, - label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', - }; - }, - }, - methods: { - ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), - setModalDescription(itemIndex = -1) { - if (itemIndex === -1) { - this.modalDescription = sprintf( - s__(`ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?`), - { count: this.itemsToBeDeleted.length }, - ); - } else { - const { tag } = this.repo.list[itemIndex]; - - this.modalDescription = sprintf( - s__(`ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?`), - { title: `${this.repo.name}:${tag}` }, - ); - } - }, - layers(item) { - return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; - }, - formatSize(size) { - return numberToHumanSize(size); - }, - deleteSingleItem(index) { - this.setModalDescription(index); - this.itemsToBeDeleted = [index]; - this.track('click_button'); - this.$refs.deleteModal.show(); - }, - deleteMultipleItems() { - this.itemsToBeDeleted = [...this.selectedItems]; - if (this.selectedItems.length === 1) { - this.setModalDescription(this.itemsToBeDeleted[0]); - } else if (this.selectedItems.length > 1) { - this.setModalDescription(); - } - this.track('click_button'); - this.$refs.deleteModal.show(); - }, - handleSingleDelete(itemToDelete) { - this.itemsToBeDeleted = []; - this.deleteItem(itemToDelete) - .then(() => this.fetchList({ repo: this.repo })) - .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); - }, - handleMultipleDelete() { - const { itemsToBeDeleted } = this; - this.itemsToBeDeleted = []; - this.selectedItems = []; - - if (this.bulkDeletePath) { - this.multiDeleteItems({ - path: this.bulkDeletePath, - items: itemsToBeDeleted.map(x => this.repo.list[x].tag), - }) - .then(() => this.fetchList({ repo: this.repo })) - .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); - } else { - createFlash(DELETE_REGISTRY_ERROR_MESSAGE); - } - }, - onPageChange(pageNumber) { - this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => - createFlash(FETCH_REGISTRY_ERROR_MESSAGE), - ); - }, - onSelectAllChange() { - if (this.selectAllChecked) { - this.deselectAll(); - } else { - this.selectAll(); - } - }, - selectAll() { - this.selectedItems = this.repo.list.map((x, index) => index); - this.selectAllChecked = true; - }, - deselectAll() { - this.selectedItems = []; - this.selectAllChecked = false; - }, - updateselectedItems(index) { - const delIndex = this.selectedItems.findIndex(x => x === index); - - if (delIndex > -1) { - this.selectedItems.splice(delIndex, 1); - this.selectAllChecked = false; - } else { - this.selectedItems.push(index); - - if (this.selectedItems.length === this.repo.list.length) { - this.selectAllChecked = true; - } - } - }, - canDeleteRow(item) { - return item && item.canDelete && !this.isDeleteDisabled; - }, - onDeletionConfirmed() { - this.track('confirm_delete'); - if (this.isMultiDelete) { - this.handleMultipleDelete(); - } else { - const index = this.itemsToBeDeleted[0]; - this.handleSingleDelete(this.repo.list[index]); - } - }, - }, -}; -</script> -<template> - <div> - <table class="table tags"> - <thead> - <tr> - <th> - <gl-form-checkbox - v-if="canDeleteRepo" - class="js-select-all-checkbox" - :checked="selectAllChecked" - @change="onSelectAllChange" - /> - </th> - <th>{{ s__('ContainerRegistry|Tag') }}</th> - <th ref="imageId">{{ s__('ContainerRegistry|Image ID') }}</th> - <th>{{ s__('ContainerRegistry|Size') }}</th> - <th>{{ s__('ContainerRegistry|Last Updated') }}</th> - <th> - <gl-button - v-if="canDeleteRepo" - ref="bulkDeleteButton" - v-gl-tooltip - :disabled="!selectedItems || selectedItems.length === 0" - class="float-right" - variant="danger" - :title="s__('ContainerRegistry|Remove selected tags')" - :aria-label="s__('ContainerRegistry|Remove selected tags')" - @click="deleteMultipleItems()" - > - <icon name="remove" /> - </gl-button> - </th> - </tr> - </thead> - <tbody> - <tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row"> - <td class="check"> - <gl-form-checkbox - v-if="canDeleteRow(item)" - class="js-select-checkbox" - :checked="selectedItems && selectedItems.includes(index)" - @change="updateselectedItems(index)" - /> - </td> - <td class="monospace"> - {{ item.tag }} - <clipboard-button - v-if="item.location" - :title="item.location" - :text="item.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </td> - <td> - <span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{ - item.shortRevision - }}</span> - </td> - <td> - {{ formatSize(item.size) }} - <template v-if="item.size && item.layers" - >·</template - > - {{ layers(item) }} - </td> - - <td> - <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{ - timeFormatted(item.createdAt) - }}</span> - </td> - - <td class="content action-buttons"> - <gl-button - v-if="canDeleteRow(item)" - :title="s__('ContainerRegistry|Remove tag')" - :aria-label="s__('ContainerRegistry|Remove tag')" - variant="danger" - class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" - @click="deleteSingleItem(index)" - > - <icon name="remove" /> - </gl-button> - </td> - </tr> - </tbody> - </table> - - <table-pagination - v-if="shouldRenderPagination" - :change="onPageChange" - :page-info="repo.pagination" - class="js-registry-pagination" - /> - - <gl-modal - ref="deleteModal" - :modal-id="modalId" - ok-variant="danger" - @ok="onDeletionConfirmed" - @cancel="track('cancel_delete')" - > - <template v-slot:modal-title>{{ modalAction }}</template> - <template v-slot:modal-ok>{{ modalAction }}</template> - <p v-html="modalDescription"></p> - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/registry/list/constants.js b/app/assets/javascripts/registry/list/constants.js deleted file mode 100644 index e55ea9cc9d9..00000000000 --- a/app/assets/javascripts/registry/list/constants.js +++ /dev/null @@ -1,8 +0,0 @@ -import { __ } from '~/locale'; - -export const FETCH_REGISTRY_ERROR_MESSAGE = __( - 'Something went wrong while fetching the registry list.', -); -export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.'); -export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.'); -export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.'); diff --git a/app/assets/javascripts/registry/list/index.js b/app/assets/javascripts/registry/list/index.js deleted file mode 100644 index e8e54fda169..00000000000 --- a/app/assets/javascripts/registry/list/index.js +++ /dev/null @@ -1,44 +0,0 @@ -import Vue from 'vue'; -import registryApp from './components/app.vue'; -import Translate from '~/vue_shared/translate'; - -Vue.use(Translate); - -export default () => { - const el = document.getElementById('js-vue-registry-images'); - - if (!el) { - return null; - } - - return new Vue({ - el, - components: { - registryApp, - }, - data() { - const { dataset } = el; - return { - registryData: { - endpoint: dataset.endpoint, - characterError: Boolean(dataset.characterError), - helpPagePath: dataset.helpPagePath, - noContainersImage: dataset.noContainersImage, - containersErrorImage: dataset.containersErrorImage, - repositoryUrl: dataset.repositoryUrl, - isGroupPage: dataset.isGroupPage, - personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink, - registryHostUrlWithPort: dataset.registryHostUrlWithPort, - twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink, - }, - }; - }, - render(createElement) { - return createElement('registry-app', { - props: { - ...this.registryData, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/registry/list/stores/actions.js b/app/assets/javascripts/registry/list/stores/actions.js deleted file mode 100644 index 6afba618486..00000000000 --- a/app/assets/javascripts/registry/list/stores/actions.js +++ /dev/null @@ -1,46 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; -import * as types from './mutation_types'; -import { FETCH_REPOS_ERROR_MESSAGE, FETCH_REGISTRY_ERROR_MESSAGE } from '../constants'; - -export const fetchRepos = ({ commit, state }) => { - commit(types.TOGGLE_MAIN_LOADING); - - return axios - .get(state.endpoint) - .then(({ data }) => { - commit(types.TOGGLE_MAIN_LOADING); - commit(types.SET_REPOS_LIST, data); - }) - .catch(() => { - commit(types.TOGGLE_MAIN_LOADING); - createFlash(FETCH_REPOS_ERROR_MESSAGE); - }); -}; - -export const fetchList = ({ commit }, { repo, page }) => { - commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - return axios - .get(repo.tagsPath, { params: { page } }) - .then(response => { - const { headers, data } = response; - - commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - commit(types.SET_REGISTRY_LIST, { repo, resp: data, headers }); - }) - .catch(() => { - commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - createFlash(FETCH_REGISTRY_ERROR_MESSAGE); - }); -}; - -export const deleteItem = (_, item) => axios.delete(item.destroyPath); -export const multiDeleteItems = (_, { path, items }) => - axios.delete(path, { params: { ids: items } }); - -export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); -export const setIsDeleteDisabled = ({ commit }, data) => commit(types.SET_IS_DELETE_DISABLED, data); -export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/registry/list/stores/getters.js b/app/assets/javascripts/registry/list/stores/getters.js deleted file mode 100644 index ac90bde1b2a..00000000000 --- a/app/assets/javascripts/registry/list/stores/getters.js +++ /dev/null @@ -1,6 +0,0 @@ -export const isLoading = state => state.isLoading; -export const repos = state => state.repos; -export const isDeleteDisabled = state => state.isDeleteDisabled; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/registry/list/stores/index.js b/app/assets/javascripts/registry/list/stores/index.js deleted file mode 100644 index 1bb06bd6e81..00000000000 --- a/app/assets/javascripts/registry/list/stores/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import createState from './state'; - -Vue.use(Vuex); - -export default new Vuex.Store({ - state: createState(), - actions, - getters, - mutations, -}); diff --git a/app/assets/javascripts/registry/list/stores/mutation_types.js b/app/assets/javascripts/registry/list/stores/mutation_types.js deleted file mode 100644 index 6740bfede1a..00000000000 --- a/app/assets/javascripts/registry/list/stores/mutation_types.js +++ /dev/null @@ -1,8 +0,0 @@ -export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT'; -export const SET_IS_DELETE_DISABLED = 'SET_IS_DELETE_DISABLED'; - -export const SET_REPOS_LIST = 'SET_REPOS_LIST'; -export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING'; - -export const SET_REGISTRY_LIST = 'SET_REGISTRY_LIST'; -export const TOGGLE_REGISTRY_LIST_LOADING = 'TOGGLE_REGISTRY_LIST_LOADING'; diff --git a/app/assets/javascripts/registry/list/stores/mutations.js b/app/assets/javascripts/registry/list/stores/mutations.js deleted file mode 100644 index 419de848883..00000000000 --- a/app/assets/javascripts/registry/list/stores/mutations.js +++ /dev/null @@ -1,57 +0,0 @@ -import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; - -export default { - [types.SET_MAIN_ENDPOINT](state, endpoint) { - state.endpoint = endpoint; - }, - - [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) { - state.isDeleteDisabled = isDeleteDisabled; - }, - - [types.SET_REPOS_LIST](state, list) { - state.repos = list.map(el => ({ - canDelete: Boolean(el.destroy_path), - destroyPath: el.destroy_path, - id: el.id, - isLoading: false, - list: [], - location: el.location, - name: el.path, - tagsPath: el.tags_path, - projectId: el.project_id, - })); - }, - - [types.TOGGLE_MAIN_LOADING](state) { - state.isLoading = !state.isLoading; - }, - - [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) { - const listToUpdate = state.repos.find(el => el.id === repo.id); - - const normalizedHeaders = normalizeHeaders(headers); - const pagination = parseIntPagination(normalizedHeaders); - - listToUpdate.pagination = pagination; - - listToUpdate.list = resp.map(element => ({ - tag: element.name, - revision: element.revision, - shortRevision: element.short_revision, - size: element.total_size, - layers: element.layers, - location: element.location, - createdAt: element.created_at, - destroyPath: element.destroy_path, - canDelete: Boolean(element.destroy_path), - })); - }, - - [types.TOGGLE_REGISTRY_LIST_LOADING](state, list) { - const listToUpdate = state.repos.find(el => el.id === list.id); - - listToUpdate.isLoading = !listToUpdate.isLoading; - }, -}; diff --git a/app/assets/javascripts/registry/list/stores/state.js b/app/assets/javascripts/registry/list/stores/state.js deleted file mode 100644 index 724c64b4994..00000000000 --- a/app/assets/javascripts/registry/list/stores/state.js +++ /dev/null @@ -1,27 +0,0 @@ -export default () => ({ - isLoading: false, - endpoint: '', // initial endpoint to fetch the repos list - isDeleteDisabled: false, // controls the delete buttons in the registry - /** - * Each object in `repos` has the following strucure: - * { - * name: String, - * isLoading: Boolean, - * tagsPath: String // endpoint to request the list - * destroyPath: String // endpoit to delete the repo - * list: Array // List of the registry images - * } - * - * Each registry image inside `list` has the following structure: - * { - * tag: String, - * revision: String - * shortRevision: String - * size: Number - * layers: Number - * createdAt: String - * destroyPath: String // endpoit to delete each image - * } - */ - repos: [], -}); diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index 012a4f4ad89..728f655d33d 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -106,17 +106,17 @@ export default { <div class="title hide-collapsed"> {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} - <button + <a v-if="isEditable" class="float-right lock-edit" - type="button" + href="#" data-track-event="click_edit_button" data-track-label="right_sidebar" data-track-property="lock_issue" @click.prevent="toggleForm" > {{ __('Edit') }} - </button> + </a> </div> <div class="value sidebar-item-value hide-collapsed"> diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index f2ef7a2268e..db2e51c3aca 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -127,7 +127,7 @@ export default { <div v-if="hasMoreParticipants" class="participants-more hide-collapsed"> <button type="button" - class="btn-transparent btn-blank js-toggle-participants-button" + class="btn-transparent btn-link js-toggle-participants-button" @click="toggleMoreParticipants" > {{ toggleLabel }} diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 38cef3de777..6257ee3ae8e 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -447,6 +447,7 @@ font-weight: normal; border-radius: 0; border-color: transparent; + border-width: 0; &:hover, &:active, diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index d61fbae81f2..c48f4b0622e 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -187,7 +187,6 @@ .btn-link { color: inherit; - outline: none; } .issuable-header-text { @@ -261,15 +260,10 @@ color: rgba($gray-normal, 0.2); } - .lock-edit, // uses same style, different js behaviour + .confidential-edit, + .lock-edit, .edit-link { - @extend .btn-blank; - color: $gl-text-color; - - &:hover { - text-decoration: underline; - color: $blue-800; - } + @extend .btn-link; } } @@ -689,7 +683,6 @@ } .btn-link { - outline: none; padding: 0; } diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index ccb9dd7bbf6..1bfff210ecf 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -21,7 +21,7 @@ module Boards before_action :validate_id_list, only: [:bulk_move] before_action :can_move_issues?, only: [:bulk_move] before_action do - push_frontend_feature_flag(:board_search_optimization, board.group) + push_frontend_feature_flag(:board_search_optimization, board.group, default_enabled: true) end def index diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index b12aee346ed..096c6efc0fc 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -4,6 +4,10 @@ module SnippetsActions extend ActiveSupport::Concern include SendsBlob + included do + before_action :redirect_if_binary, only: [:edit, :update] + end + def edit # We need to load some info from the existing blob snippet.content = blob.data @@ -67,4 +71,8 @@ module SnippetsActions flash.now[:alert] = repository_errors.first if repository_errors.present? recaptcha_check_with_fallback(repository_errors.empty?) { render :edit } end + + def redirect_if_binary + redirect_to gitlab_snippet_path(snippet) if blob&.binary? + end end diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index ac4ca197d72..0240c87e699 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -4,7 +4,6 @@ module Groups class RepositoriesController < Groups::ApplicationController before_action :verify_container_registry_enabled! before_action :authorize_read_container_image! - before_action :feature_flag_group_container_registry_browser! def index respond_to do |format| @@ -17,12 +16,8 @@ module Groups serializer = ContainerRepositoriesSerializer .new(current_user: current_user) - if Feature.enabled?(:vue_container_registry_explorer, group) - render json: serializer.with_pagination(request, response) - .represent_read_only(@images) - else - render json: serializer.represent_read_only(@images) - end + render json: serializer.with_pagination(request, response) + .represent_read_only(@images) end end end @@ -34,10 +29,6 @@ module Groups private - def feature_flag_group_container_registry_browser! - render_404 unless Feature.enabled?(:group_container_registry_browser, group) - end - def verify_container_registry_enabled! render_404 unless Gitlab.config.registry.enabled end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index d6d993f427d..8852ae04d5e 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -17,11 +17,7 @@ module Projects serializer = ContainerRepositoriesSerializer .new(project: project, current_user: current_user) - if Feature.enabled?(:vue_container_registry_explorer, project.group) - render json: serializer.with_pagination(request, response).represent(@images) - else - render json: serializer.represent(@images) - end + render json: serializer.with_pagination(request, response).represent(@images) end end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 661197e84ae..5abe6a635e0 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -22,8 +22,7 @@ module GroupsHelper def group_container_registry_nav? Gitlab.config.registry.enabled && - can?(current_user, :read_container_image, @group) && - Feature.enabled?(:group_container_registry_browser, @group) + can?(current_user, :read_container_image, @group) end def group_sidebar_links diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index d50e088944e..01f1aa1758f 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -111,4 +111,8 @@ module HasRepository def web_url(only_path: nil) raise NotImplementedError end + + def repository_size_checker + raise NotImplementedError + end end diff --git a/app/services/award_emojis/add_service.rb b/app/services/award_emojis/add_service.rb index eac15dabbf0..db465dcaf7e 100644 --- a/app/services/award_emojis/add_service.rb +++ b/app/services/award_emojis/add_service.rb @@ -16,7 +16,7 @@ module AwardEmojis award = awardable.award_emoji.create(name: name, user: current_user) if award.persisted? - TodoService.new.new_award_emoji(todoable, current_user) if todoable + after_create(award) success(award: award) else error(award.errors.full_messages, award: award) @@ -25,6 +25,10 @@ module AwardEmojis private + def after_create(award) + TodoService.new.new_award_emoji(todoable, current_user) if todoable + end + def todoable strong_memoize(:todoable) do case awardable @@ -40,3 +44,5 @@ module AwardEmojis end end end + +AwardEmojis::AddService.prepend_if_ee('EE::AwardEmojis::AddService') diff --git a/app/services/award_emojis/destroy_service.rb b/app/services/award_emojis/destroy_service.rb index 3789a8403bc..a61a7911a9d 100644 --- a/app/services/award_emojis/destroy_service.rb +++ b/app/services/award_emojis/destroy_service.rb @@ -14,8 +14,16 @@ module AwardEmojis end award = awards.destroy_all.first # rubocop: disable DestroyAll + after_destroy(award) success(award: award) end + + private + + def after_destroy(award) + end end end + +AwardEmojis::DestroyService.prepend_if_ee('EE::AwardEmojis::DestroyService') diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 337710b60e0..9637eb1b918 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -133,7 +133,7 @@ module Boards def can_attempt_search_optimization? params[:search].present? && - Feature.enabled?(:board_search_optimization, board_group, default_enabled: false) + Feature.enabled?(:board_search_optimization, board_group, default_enabled: true) end end end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 2cf4bbcd590..8594808cd44 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -59,8 +59,6 @@ module Issues end def store_first_mentioned_in_commit_at(issue, merge_request) - return unless Feature.enabled?(:store_first_mentioned_in_commit_on_issue_close, issue.project, default_enabled: true) - metrics = issue.metrics return if metrics.nil? || metrics.first_mentioned_in_commit_at diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index 996582e0ae8..41cb073686a 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -4,23 +4,14 @@ %section .row.registry-placeholder.prepend-bottom-10 .col-12 - - if Feature.enabled?(:vue_container_registry_explorer, @group) - #js-container-registry{ data: { endpoint: group_container_registries_path(@group), - "help_page_path" => help_page_path('user/packages/container_registry/index'), - "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), - "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), - "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), - "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "registry_host_url_with_port" => escape_once(registry_config.host_port), - "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), - "is_admin": current_user&.admin, - is_group_page: true, - character_error: @character_error.to_s } } - - else - #js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json), - "help_page_path" => help_page_path('user/packages/container_registry/index'), - "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), - "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => "", - is_group_page: true, - character_error: @character_error.to_s } } + #js-container-registry{ data: { endpoint: group_container_registries_path(@group), + "help_page_path" => help_page_path('user/packages/container_registry/index'), + "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), + "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "registry_host_url_with_port" => escape_once(registry_config.host_port), + "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), + "is_admin": current_user&.admin, + is_group_page: true, + character_error: @character_error.to_s } } diff --git a/app/views/projects/blob/_pipeline_tour_success.html.haml b/app/views/projects/blob/_pipeline_tour_success.html.haml index 7ecbc1974ec..cf1427df044 100644 --- a/app/views/projects/blob/_pipeline_tour_success.html.haml +++ b/app/views/projects/blob/_pipeline_tour_success.html.haml @@ -1 +1,3 @@ -.js-success-pipeline-modal{ 'data-commit-cookie': suggest_pipeline_commit_cookie_name, 'data-pipelines-path': project_pipelines_path(@project) } +.js-success-pipeline-modal{ data: { 'commit-cookie': suggest_pipeline_commit_cookie_name, + 'go-to-pipelines-path': project_pipelines_path(@project), + 'human-access': @project.team.human_max_access(current_user&.id) } } diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 80dd5eaecb2..650e63eb406 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -4,28 +4,17 @@ %section .row.registry-placeholder.prepend-bottom-10 .col-12 - - if Feature.enabled?(:vue_container_registry_explorer, @project.group) - #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), - settings_path: project_settings_ci_cd_path(@project), - expiration_policy: @project.container_expiration_policy.to_json, - "help_page_path" => help_page_path('user/packages/container_registry/index'), - "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), - "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), - "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), - "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => escape_once(@project.container_registry_url), - "registry_host_url_with_port" => escape_once(registry_config.host_port), - "expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), - "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), - "is_admin": current_user&.admin, - character_error: @character_error.to_s } } - - else - #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json), - "help_page_path" => help_page_path('user/packages/container_registry/index'), - "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), - "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), - "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), - "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => escape_once(@project.container_registry_url), - "registry_host_url_with_port" => escape_once(registry_config.host_port), - character_error: @character_error.to_s } } + #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), + settings_path: project_settings_ci_cd_path(@project), + expiration_policy: @project.container_expiration_policy.to_json, + "help_page_path" => help_page_path('user/packages/container_registry/index'), + "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), + "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "repository_url" => escape_once(@project.container_registry_url), + "registry_host_url_with_port" => escape_once(registry_config.host_port), + "expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), + "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), + "is_admin": current_user&.admin, + character_error: @character_error.to_s } } diff --git a/changelogs/unreleased/212662-edit-snippet-images.yml b/changelogs/unreleased/212662-edit-snippet-images.yml new file mode 100644 index 00000000000..1a23976db5b --- /dev/null +++ b/changelogs/unreleased/212662-edit-snippet-images.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Snippet actions with binary data +merge_request: 28191 +author: +type: fixed diff --git a/changelogs/unreleased/23315-remove-feature-flag.yml b/changelogs/unreleased/23315-remove-feature-flag.yml new file mode 100644 index 00000000000..b3478ec8e00 --- /dev/null +++ b/changelogs/unreleased/23315-remove-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Enable container registry at the group level +merge_request: 27814 +author: +type: added diff --git a/changelogs/unreleased/35947-board-issues-search-optmization-2.yml b/changelogs/unreleased/35947-board-issues-search-optmization-2.yml new file mode 100644 index 00000000000..be9519df1b0 --- /dev/null +++ b/changelogs/unreleased/35947-board-issues-search-optmization-2.yml @@ -0,0 +1,5 @@ +--- +title: Use CTE optimization for searching board issues +merge_request: 28430 +author: +type: fixed diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md index dcbaff70633..af8c6a3feb0 100644 --- a/doc/user/application_security/dast/index.md +++ b/doc/user/application_security/dast/index.md @@ -253,6 +253,85 @@ LoadModule proxy_http_module modules/mod_proxy_http.so [This snippet](https://gitlab.com/gitlab-org/security-products/dast/snippets/1894732) contains a complete `httpd.conf` file configured to act as a remote proxy and add the `Gitlab-DAST-Permission` header. +### API scan + +Using an API specification as a scan's target is a useful way to seed URLs for scanning an API. +Vulnerability rules in an API scan are different than those in a normal website scan. + +#### Specification format + +API scans support OpenAPI V2 and OpenAPI V3 specifications. You can define these specifications using `JSON` or `YAML`. + +#### Import API specification from a URL + +If your API specification is accessible at a URL, you can pass that URL in directly as the target. +The specification doesn't have to be hosted on the same host as the API being tested. + +```yml +include: + - template: DAST.gitlab-ci.yml + +variables: + DAST_API_SPECIFICATION: http://my.api/api-specification.yml +``` + +#### Import API specification from a file + +If your API specification is in your repository, you can provide the specification's filename directly as the target. The specification file is expected to be in the `/zap/wrk` directory. + +```yml +dast: + script: + - mkdir -p /zap/wrk + - cp api-specification.yml /zap/wrk/api-specification.yml + - /analyze -t $DAST_WEBSITE + variables: + GIT_STRATEGY: fetch + DAST_API_SPECIFICATION: api-specification.yml +``` + +#### Full scan + +API scans support full scanning, which can be enabled by using the `DAST_FULL_SCAN_ENABLED` environment variable. Domain validation isn't supported for full API scans. + +#### Host override + +Specifications often define a host, which contains a domain name and a port. The host referenced may be different than the host of the API's review instance. +This can cause incorrect URLs to be imported, or a scan on an incorrect host. Use the `DAST_API_HOST_OVERRIDE` environment variable to override these values. + +For example, with a OpenAPI V3 specification containing: + +```yml +servers: + - url: https://api.host.com +``` + +If the test version of the API is running at `https://api-test.host.com`, then the following DAST configuration can be used: + +```yml +include: + - template: DAST.gitlab-ci.yml + +variables: + DAST_API_SPECIFICATION: http://api-test.host.com/api-specification.yml + DAST_API_HOST_OVERRIDE: api-test.host.com +``` + +Note that `DAST_API_HOST_OVERRIDE` is only applied to specifications imported by URL. + +#### Authentication using headers + +Tokens in request headers are often used as a way to authenticate API requests. You can achieve this by using the `DAST_REQUEST_HEADERS` environment variable. Headers are applied to every request DAST makes. + +```yml +include: + - template: DAST.gitlab-ci.yml + +variables: + DAST_API_SPECIFICATION: http://api-test.api.com/api-specification.yml + DAST_REQUEST_HEADERS: "Authorization: Bearer my.token" +``` + ### Customizing the DAST settings The DAST settings can be changed through environment variables by using the @@ -300,17 +379,21 @@ DAST can be [configured](#customizing-the-dast-settings) using environment varia | Environment variable | Required | Description | |-----------------------------| ----------|--------------------------------------------------------------------------------| -| `DAST_WEBSITE` | yes | The URL of the website to scan. | -| `DAST_AUTH_URL` | no | The authentication URL of the website to scan. | +| `DAST_WEBSITE` | no| The URL of the website to scan. `DAST_API_SPECIFICATION` must be specified if this is omitted. | +| `DAST_API_SPECIFICATION` | no | The API specification to import. `DAST_WEBSITE` must be specified if this is omitted. | +| `DAST_AUTH_URL` | no | The authentication URL of the website to scan. Not supported for API scans. | | `DAST_USERNAME` | no | The username to authenticate to in the website. | | `DAST_PASSWORD` | no | The password to authenticate to in the website. | | `DAST_USERNAME_FIELD` | no | The name of username field at the sign-in HTML form. | | `DAST_PASSWORD_FIELD` | no | The name of password field at the sign-in HTML form. | -| `DAST_AUTH_EXCLUDE_URLS` | no | The URLs to skip during the authenticated scan; comma-separated, no spaces in between. | +| `DAST_AUTH_EXCLUDE_URLS` | no | The URLs to skip during the authenticated scan; comma-separated, no spaces in between. Not supported for API scans. | | `DAST_TARGET_AVAILABILITY_TIMEOUT` | no | Time limit in seconds to wait for target availability. Scan is attempted nevertheless if it runs out. Integer. Defaults to `60`. | | `DAST_FULL_SCAN_ENABLED` | no | Switches the tool to execute [ZAP Full Scan](https://github.com/zaproxy/zaproxy/wiki/ZAP-Full-Scan) instead of [ZAP Baseline Scan](https://github.com/zaproxy/zaproxy/wiki/ZAP-Baseline-Scan). Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. | -| `DAST_FULL_SCAN_DOMAIN_VALIDATION_REQUIRED` | no | Requires [domain validation](#domain-validation) when running DAST full scans. Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. | +| `DAST_FULL_SCAN_DOMAIN_VALIDATION_REQUIRED` | no | Requires [domain validation](#domain-validation) when running DAST full scans. Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. Not supported for API scans. | | `DAST_AUTO_UPDATE_ADDONS` | no | Set to `false` to pin the versions of ZAProxy add-ons to those provided with the DAST image. Defaults to `true`. | +| `DAST_API_HOST_OVERRIDE` | no | Used to override domains defined in API specification files. | +| `DAST_EXCLUDE_RULES` | no | Set to a comma-separated list of Vulnerability Rule IDs to exclude them from scans. Rule IDs are numbers and can be found from the DAST log or on the [ZAP project](https://github.com/zaproxy/zaproxy/blob/master/docs/scanners.md). For example, `HTTP Parameter Override` has a rule ID of `10026`. | +| `DAST_REQUEST_HEADERS` | no | Set to a comma-separated list of request header names and values. For example, `Cache-control: no-cache,User-Agent: DAST/1.0` | ### DAST command-line options diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index 359d548e236..cf8d63e1512 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -316,6 +316,53 @@ For example, to unlink the `MyOrg` account, the following **Disconnect** button | Issuer | How GitLab identifies itself to the identity provider. Also known as a "Relying party trust identifier". | | Certificate fingerprint | Used to confirm that communications over SAML are secure by checking that the server is signing communications with the correct certificate. Also known as a certificate thumbprint. | +## Configuring on a self-managed GitLab instance + +For self-managed GitLab instances we strongly recommend using the +[instance-wide SAML OmniAuth Provider](../../../integration/saml.md) instead. + +Group SAML SSO helps if you need to allow access via multiple SAML identity providers, but as a multi-tenant solution is less suited to cases where you administer your own GitLab instance. + +To proceed with configuring Group SAML SSO instead, you'll need to enable the `group_saml` OmniAuth provider. This can be done from: + +- `gitlab.rb` for GitLab [Omnibus installations](#omnibus-installations). +- `gitlab/config/gitlab.yml` for [source installations](#source-installations). + +### Limitations + +Group SAML on a self-managed instance is limited when compared to the recommended +[instance-wide SAML](../../../integration/saml.md). The recommended solution allows you to take advantage of: + +- [LDAP compatibility](../../../administration/auth/ldap.md). +- [LDAP group Sync](../../../administration/auth/how_to_configure_ldap_gitlab_ee/index.md#group-sync). +- [Required groups](../../../integration/saml.md#required-groups-starter-only). +- [Admin groups](../../../integration/saml.md#admin-groups-starter-only). +- [Auditor groups](../../../integration/saml.md#auditor-groups-starter-only). + +### Omnibus installations + +1. Make sure GitLab is + [configured with HTTPS](../../../install/installation.md#using-https). +1. Enable OmniAuth and the `group_saml` provider in `gitlab.rb`: + + ```ruby + gitlab_rails['omniauth_enabled'] = true + gitlab_rails['omniauth_providers'] = [{ name: 'group_saml' }] + ``` + +### Source installations + +1. Make sure GitLab is + [configured with HTTPS](../../../install/installation.md#using-https). +1. Enable OmniAuth and the `group_saml` provider in `gitlab/config/gitlab.yml`: + + ```yaml + omniauth: + enabled: true + providers: + - { name: 'group_saml' } + ``` + ## Troubleshooting This section contains possible solutions for problems you might encounter. diff --git a/lib/gitlab/repository_size_checker.rb b/lib/gitlab/repository_size_checker.rb new file mode 100644 index 00000000000..cf1af844439 --- /dev/null +++ b/lib/gitlab/repository_size_checker.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Gitlab + # Centralized class for repository size related calculations. + class RepositorySizeChecker + attr_reader :limit + + def initialize(current_size_proc:, limit:, enabled: true) + @current_size_proc = current_size_proc + @limit = limit + @enabled = enabled && limit != 0 + end + + def current_size + @current_size ||= @current_size_proc.call + end + + def enabled? + @enabled + end + + def above_size_limit? + return false unless enabled? + + current_size > limit + end + + # @param change_size [int] in bytes + def changes_will_exceed_size_limit?(change_size) + return false unless enabled? + + change_size > limit || exceeded_size(change_size) > 0 + end + + # @param change_size [int] in bytes + def exceeded_size(change_size = 0) + current_size + change_size - limit + end + + def error_message + @error_message_object ||= Gitlab::RepositorySizeErrorMessage.new(self) + end + end +end diff --git a/lib/gitlab/repository_size_error_message.rb b/lib/gitlab/repository_size_error_message.rb new file mode 100644 index 00000000000..556190453de --- /dev/null +++ b/lib/gitlab/repository_size_error_message.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Gitlab + class RepositorySizeErrorMessage + include ActiveSupport::NumberHelper + + delegate :current_size, :limit, :exceeded_size, to: :@checker + + # @param checher [RepositorySizeChecker] + def initialize(checker) + @checker = checker + end + + def commit_error + "Your changes could not be committed, #{base_message}" + end + + def merge_error + "This merge request cannot be merged, #{base_message}" + end + + def push_error(change_size = 0) + "Your push has been rejected, #{base_message(change_size)}. #{more_info_message}" + end + + def new_changes_error + "Your push to this repository would cause it to exceed the size limit of #{formatted(limit)} so it has been rejected. #{more_info_message}" + end + + def more_info_message + 'Please contact your GitLab administrator for more information.' + end + + def above_size_limit_message + "The size of this repository (#{formatted(current_size)}) exceeds the limit of #{formatted(limit)} by #{formatted(exceeded_size)}. You won't be able to push new code to this project. #{more_info_message}" + end + + private + + def base_message(change_size = 0) + "because this repository has exceeded its size limit of #{formatted(limit)} by #{formatted(exceeded_size(change_size))}" + end + + def formatted(number) + number_to_human_size(number, delimiter: ',', precision: 2) + end + end +end diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb index 88094839062..9213b5ebab2 100644 --- a/lib/gitlab/url_blocker.rb +++ b/lib/gitlab/url_blocker.rb @@ -11,8 +11,8 @@ module Gitlab # Validates the given url according to the constraints specified by arguments. # # ports - Raises error if the given URL port does is not between given ports. - # allow_localhost - Raises error if URL resolves to a localhost IP address and argument is true. - # allow_local_network - Raises error if URL resolves to a link-local address and argument is true. + # allow_localhost - Raises error if URL resolves to a localhost IP address and argument is false. + # allow_local_network - Raises error if URL resolves to a link-local address and argument is false. # ascii_only - Raises error if URL has unicode characters and argument is true. # enforce_user - Raises error if URL user doesn't start with alphanumeric characters and argument is true. # enforce_sanitization - Raises error if URL includes any HTML/CSS/JS tags and argument is true. diff --git a/lib/sentry/client.rb b/lib/sentry/client.rb index 36ec1caf80c..dbf54a65081 100644 --- a/lib/sentry/client.rb +++ b/lib/sentry/client.rb @@ -10,7 +10,6 @@ module Sentry Error = Class.new(StandardError) MissingKeysError = Class.new(StandardError) - ResponseInvalidSizeError = Class.new(StandardError) attr_accessor :url, :token diff --git a/lib/sentry/client/issue.rb b/lib/sentry/client/issue.rb index 986311ab62a..4a62b73a349 100644 --- a/lib/sentry/client/issue.rb +++ b/lib/sentry/client/issue.rb @@ -4,6 +4,7 @@ module Sentry class Client module Issue BadRequestError = Class.new(StandardError) + ResponseInvalidSizeError = Class.new(StandardError) SENTRY_API_SORT_VALUE_MAP = { # <accepted_by_client> => <accepted_by_sentry_api> diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0b2ea946dfa..2a38073ba92 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5395,9 +5395,6 @@ msgstr[1] "" msgid "ContainerRegistry|Retention policy has been Enabled" msgstr "" -msgid "ContainerRegistry|Size" -msgstr "" - msgid "ContainerRegistry|Something went wrong while deleting the image." msgstr "" @@ -5479,18 +5476,9 @@ msgstr "" msgid "ContainerRegistry|You are about to remove %{item}. Are you sure?" msgstr "" -msgid "ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?" -msgstr "" - -msgid "ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?" -msgstr "" - msgid "ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted." msgstr "" -msgid "ContainerRegistry|You are about to remove repository <b>%{title}</b>. Once you confirm, this repository will be permanently deleted." -msgstr "" - msgid "ContainerRegistry|You can add an image to this registry with the following commands:" msgstr "" @@ -10747,9 +10735,6 @@ msgstr "" msgid "Ignored" msgstr "" -msgid "Image %{imageName} was scheduled for deletion from the registry." -msgstr "" - msgid "Image: %{image}" msgstr "" @@ -12051,6 +12036,9 @@ msgstr "" msgid "Live preview" msgstr "" +msgid "Load more vulnerabilities" +msgstr "" + msgid "Loading" msgstr "" @@ -18678,12 +18666,6 @@ msgstr "" msgid "Something went wrong while fetching the packages list." msgstr "" -msgid "Something went wrong while fetching the projects." -msgstr "" - -msgid "Something went wrong while fetching the registry list." -msgstr "" - msgid "Something went wrong while initializing the OpenAPI viewer" msgstr "" diff --git a/spec/controllers/groups/registry/repositories_controller_spec.rb b/spec/controllers/groups/registry/repositories_controller_spec.rb index 3a74aa1dac0..eadc3a7f739 100644 --- a/spec/controllers/groups/registry/repositories_controller_spec.rb +++ b/spec/controllers/groups/registry/repositories_controller_spec.rb @@ -15,7 +15,7 @@ describe Groups::Registry::RepositoriesController do end shared_examples 'renders a list of repositories' do - context 'when container registry is enabled' do + context 'when user has access to registry' do it 'show index page' do expect(Gitlab::Tracking).not_to receive(:event) @@ -63,21 +63,7 @@ describe Groups::Registry::RepositoriesController do end end - context 'container registry is disabled' do - before do - stub_container_registry_config(enabled: false) - end - - it 'renders not found' do - get :index, params: { - group_id: group - } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'user do not have acces to container registry' do + context 'user does not have access to container registry' do before do sign_out(user) sign_in(guest) @@ -90,22 +76,6 @@ describe Groups::Registry::RepositoriesController do expect(response).to have_gitlab_http_status(:not_found) end end - - context 'with :vue_container_registry_explorer feature flag disabled' do - before do - stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: group }) - end - - it 'has the correct response schema' do - get :index, params: { - group_id: group, - format: :json - } - - expect(response).to match_response_schema('registry/repositories') - expect(response).not_to include_pagination_headers - end - end end context 'GET #index' do diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb index a64673a7f87..e68959b6a96 100644 --- a/spec/controllers/projects/registry/repositories_controller_spec.rb +++ b/spec/controllers/projects/registry/repositories_controller_spec.rb @@ -85,22 +85,6 @@ describe Projects::Registry::RepositoriesController do end end end - - context 'with :vue_container_registry_explorer feature flag disabled' do - before do - stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: project.group }) - stub_container_registry_tags(repository: project.full_path, - tags: %w[rc1 latest]) - end - - it 'json has a list of projects' do - go_to_index(format: :json) - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('registry/repositories') - expect(response).not_to include_pagination_headers - end - end end describe 'GET #index' do diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb index 9fa07cbc7a6..284789305e2 100644 --- a/spec/controllers/projects/snippets_controller_spec.rb +++ b/spec/controllers/projects/snippets_controller_spec.rb @@ -116,7 +116,7 @@ describe Projects::SnippetsController do end context 'when the snippet is public' do - it 'rejects the shippet' do + it 'rejects the snippet' do expect { create_snippet(project, visibility_level: Snippet::PUBLIC) } .not_to change { Snippet.count } expect(response).to render_template(:new) @@ -164,6 +164,7 @@ describe Projects::SnippetsController do describe 'PUT #update' do let(:project) { create :project, :public } + let(:visibility_level) { Snippet::PUBLIC } let(:snippet) { create :project_snippet, author: user, project: project, visibility_level: visibility_level } def update_snippet(snippet_params = {}, additional_params = {}) @@ -174,13 +175,27 @@ describe Projects::SnippetsController do put :update, params: { namespace_id: project.namespace.to_param, project_id: project, - id: snippet.id, + id: snippet, project_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params) }.merge(additional_params) snippet.reload end + it_behaves_like 'updating snippet checks blob is binary' do + let_it_be(:title) { 'Foo' } + let(:params) do + { + namespace_id: project.namespace.to_param, + project_id: project, + id: snippet.id, + project_snippet: { title: title } + } + end + + subject { put :update, params: params } + end + context 'when the snippet is spam' do before do allow_next_instance_of(Spam::AkismetService) do |instance| @@ -198,9 +213,7 @@ describe Projects::SnippetsController do end context 'when the snippet is public' do - let(:visibility_level) { Snippet::PUBLIC } - - it 'rejects the shippet' do + it 'rejects the snippet' do expect { update_snippet(title: 'Foo') } .not_to change { snippet.reload.title } end @@ -245,7 +258,7 @@ describe Projects::SnippetsController do context 'when the private snippet is made public' do let(:visibility_level) { Snippet::PRIVATE } - it 'rejects the shippet' do + it 'rejects the snippet' do expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) } .not_to change { snippet.reload.title } end @@ -581,4 +594,19 @@ describe Projects::SnippetsController do end end end + + describe 'GET #edit' do + it_behaves_like 'editing snippet checks blob is binary' do + let(:snippet) { create(:project_snippet, :private, project: project, author: user) } + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + id: snippet + } + end + + subject { get :edit, params: params } + end + end end diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 3025521e189..e41deae0e89 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -308,7 +308,7 @@ describe SnippetsController do end context 'when the snippet is public' do - it 'rejects the shippet' do + it 'rejects the snippet' do expect { create_snippet(visibility_level: Snippet::PUBLIC) } .not_to change { Snippet.count } end @@ -354,6 +354,7 @@ describe SnippetsController do describe 'PUT #update' do let(:project) { create :project } + let(:visibility_level) { Snippet::PUBLIC } let(:snippet) { create :personal_snippet, author: user, project: project, visibility_level: visibility_level } def update_snippet(snippet_params = {}, additional_params = {}) @@ -367,6 +368,12 @@ describe SnippetsController do snippet.reload end + it_behaves_like 'updating snippet checks blob is binary' do + let_it_be(:title) { 'Foo' } + + subject { put :update, params: { id: snippet, personal_snippet: { title: title } } } + end + context 'when the snippet is spam' do before do allow_next_instance_of(Spam::AkismetService) do |instance| @@ -429,9 +436,7 @@ describe SnippetsController do end context 'when the snippet is public' do - let(:visibility_level) { Snippet::PUBLIC } - - it 'rejects the shippet' do + it 'rejects the snippet' do expect { update_snippet(title: 'Foo') } .not_to change { snippet.reload.title } end @@ -793,4 +798,12 @@ describe SnippetsController do end end end + + describe 'GET #edit' do + it_behaves_like 'editing snippet checks blob is binary' do + let_it_be(:snippet) { create(:personal_snippet, :public, :repository, author: user) } + + subject { get :edit, params: { id: snippet } } + end + end end diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb index 76c28c17858..a75da5f1080 100644 --- a/spec/features/projects/container_registry_spec.rb +++ b/spec/features/projects/container_registry_spec.rb @@ -17,167 +17,107 @@ describe 'Container Registry', :js do stub_container_registry_tags(repository: :any, tags: []) end - describe 'Registry explorer is off' do - before do - stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: project.group }) - end + it 'has a page title set' do + visit_container_registry + + expect(page).to have_title _('Container Registry') + end - it 'has a page title set' do + context 'when there are no image repositories' do + it 'list page has no container title' do visit_container_registry - expect(page).to have_title _('Container Registry') + expect(page).to have_content _('There are no container images stored for this project') end - context 'when there are no image repositories' do - it 'user visits container registry main page' do - visit_container_registry + it 'list page has quickstart' do + visit_container_registry - expect(page).to have_content _('no container images') - end + expect(page).to have_content _('Quick Start') end + end - context 'when there are image repositories' do - before do - stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true) - project.container_repositories << container_repository - end - - it 'user wants to see multi-level container repository' do - visit_container_registry - - expect(page).to have_content 'my/image' - end - - it 'user removes entire container repository', :sidekiq_might_not_need_inline do - visit_container_registry - - expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true) - - click_on(class: 'js-remove-repo') - expect(find('.modal .modal-title')).to have_content _('Remove repository') - find('.modal .modal-footer .btn-danger').click - end - - it 'user removes a specific tag from container repository' do - visit_container_registry - - find('.js-toggle-repo').click - wait_for_requests - - service = double('service') - 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-row', visible: false) - expect(find('.modal .modal-title')).to have_content _('Remove tag') - find('.modal .modal-footer .btn-danger').click - end + context 'when there are image repositories' do + before do + stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true) + project.container_repositories << container_repository end - end - describe 'Registry explorer is on' do - it 'has a page title set' do + it 'list page has a list of images' do visit_container_registry - expect(page).to have_title _('Container Registry') + expect(page).to have_content 'my/image' end - context 'when there are no image repositories' do - it 'list page has no container title' do - visit_container_registry + it 'user removes entire container repository', :sidekiq_might_not_need_inline do + visit_container_registry + + expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true) - expect(page).to have_content _('There are no container images stored for this project') - end + find('[title="Remove repository"]').click + expect(find('.modal .modal-title')).to have_content _('Remove repository') + find('.modal .modal-footer .btn-danger').click + end - it 'list page has quickstart' do - visit_container_registry + it 'navigates to repo details' do + visit_container_registry_details('my/image') - expect(page).to have_content _('Quick Start') - end + expect(page).to have_content 'latest' end - context 'when there are image repositories' do + describe 'image repo details' do before do - stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true) - project.container_repositories << container_repository + stub_container_registry_tags(repository: %r{my/image}, tags: ('1'..'20').to_a, with_manifest: true) + visit_container_registry_details 'my/image' end - it 'list page has a list of images' do - visit_container_registry - - expect(page).to have_content 'my/image' + it 'shows the details breadcrumb' do + expect(find('.breadcrumbs')).to have_link 'my/image' end - it 'user removes entire container repository', :sidekiq_might_not_need_inline do - visit_container_registry - - expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true) - - find('[title="Remove repository"]').click - expect(find('.modal .modal-title')).to have_content _('Remove repository') - find('.modal .modal-footer .btn-danger').click + it 'shows the image title' do + expect(page).to have_content 'my/image tags' end - it 'navigates to repo details' do - visit_container_registry_details('my/image') + it 'user removes a specific tag from container repository' do + service = double('service') + 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 } - expect(page).to have_content 'latest' + first('.js-delete-registry').click + expect(find('.modal .modal-title')).to have_content _('Remove tag') + find('.modal .modal-footer .btn-danger').click end - describe 'image repo details' do - before do - stub_container_registry_tags(repository: %r{my/image}, tags: ('1'..'20').to_a, with_manifest: true) - visit_container_registry_details 'my/image' - end - - it 'shows the details breadcrumb' do - expect(find('.breadcrumbs')).to have_link 'my/image' - end - - it 'shows the image title' do - expect(page).to have_content 'my/image tags' - end - - it 'user removes a specific tag from container repository' do - service = double('service') - 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 - expect(find('.modal .modal-title')).to have_content _('Remove tag') - find('.modal .modal-footer .btn-danger').click - end - - it('pagination navigate to the second page') do - visit_second_page - expect(page).to have_content '20' - end + it('pagination navigate to the second page') do + visit_second_page + expect(page).to have_content '20' end end + end - context 'when there are more than 10 images' do - before do - create_list(:container_repository, 12, project: project) - project.container_repositories << container_repository - visit_container_registry - end + context 'when there are more than 10 images' do + before do + create_list(:container_repository, 12, project: project) + project.container_repositories << container_repository + visit_container_registry + end - it 'shows pagination' do - expect(page).to have_css '.gl-pagination' - end + it 'shows pagination' do + expect(page).to have_css '.gl-pagination' + end - it 'pagination goes to second page' do - visit_second_page - expect(page).to have_content 'my/image' - end + it 'pagination goes to second page' do + visit_second_page + expect(page).to have_content 'my/image' + end - it 'pagination is preserved after navigating back from details' do - visit_second_page - click_link 'my/image' - breadcrumb = find '.breadcrumbs' - breadcrumb.click_link 'Container Registry' - expect(page).to have_content 'my/image' - end + it 'pagination is preserved after navigating back from details' do + visit_second_page + click_link 'my/image' + breadcrumb = find '.breadcrumbs' + breadcrumb.click_link 'Container Registry' + expect(page).to have_content 'my/image' end end diff --git a/spec/frontend/__mocks__/sortablejs/index.js b/spec/frontend/__mocks__/sortablejs/index.js new file mode 100644 index 00000000000..a1166d21561 --- /dev/null +++ b/spec/frontend/__mocks__/sortablejs/index.js @@ -0,0 +1,5 @@ +import Sortablejs from 'sortablejs'; + +export default Sortablejs; +export const Sortable = Sortablejs; +export class MultiDrag {} diff --git a/spec/frontend/blob/pipeline_tour_success_mock_data.js b/spec/frontend/blob/pipeline_tour_success_mock_data.js new file mode 100644 index 00000000000..7819fcce85d --- /dev/null +++ b/spec/frontend/blob/pipeline_tour_success_mock_data.js @@ -0,0 +1,7 @@ +const modalProps = { + goToPipelinesPath: 'some_pipeline_path', + commitCookie: 'some_cookie', + humanAccess: 'maintainer', +}; + +export default modalProps; diff --git a/spec/frontend/blob/pipeline_tour_success_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js index f6783b31a73..613a7ce8303 100644 --- a/spec/frontend/blob/pipeline_tour_success_spec.js +++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js @@ -2,19 +2,20 @@ import pipelineTourSuccess from '~/blob/pipeline_tour_success_modal.vue'; import { shallowMount } from '@vue/test-utils'; import Cookies from 'js-cookie'; import { GlSprintf, GlModal } from '@gitlab/ui'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import modalProps from './pipeline_tour_success_mock_data'; describe('PipelineTourSuccessModal', () => { let wrapper; let cookieSpy; - const goToPipelinesPath = 'some_pipeline_path'; - const commitCookie = 'some_cookie'; + let trackingSpy; beforeEach(() => { + document.body.dataset.page = 'projects:blob:show'; + + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); wrapper = shallowMount(pipelineTourSuccess, { - propsData: { - goToPipelinesPath, - commitCookie, - }, + propsData: modalProps, }); cookieSpy = jest.spyOn(Cookies, 'remove'); @@ -22,6 +23,7 @@ describe('PipelineTourSuccessModal', () => { afterEach(() => { wrapper.destroy(); + unmockTracking(); }); it('has expected structure', () => { @@ -35,6 +37,15 @@ describe('PipelineTourSuccessModal', () => { it('calls to remove cookie', () => { wrapper.vm.disableModalFromRenderingAgain(); - expect(cookieSpy).toHaveBeenCalledWith(commitCookie); + expect(cookieSpy).toHaveBeenCalledWith(modalProps.commitCookie); + }); + + describe('tracking', () => { + it('send event for basic view of popover', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, undefined, { + label: 'congratulate_first_pipeline', + property: modalProps.humanAccess, + }); + }); }); }); diff --git a/spec/frontend/boards/board_card_spec.js b/spec/frontend/boards/board_card_spec.js new file mode 100644 index 00000000000..2524af21826 --- /dev/null +++ b/spec/frontend/boards/board_card_spec.js @@ -0,0 +1,213 @@ +/* global List */ +/* global ListAssignee */ +/* global ListLabel */ + +import { shallowMount } from '@vue/test-utils'; + +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; + +import eventHub from '~/boards/eventhub'; +import '~/boards/models/label'; +import '~/boards/models/assignee'; +import '~/boards/models/list'; +import store from '~/boards/stores'; +import boardsStore from '~/boards/stores/boards_store'; +import boardCard from '~/boards/components/board_card.vue'; +import issueCardInner from '~/boards/components/issue_card_inner.vue'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { listObj, boardsMockInterceptor, setMockEndpoints } from './mock_data'; + +describe('Board card', () => { + let wrapper; + let mock; + let list; + + const findIssueCardInner = () => wrapper.find(issueCardInner); + const findUserAvatarLink = () => wrapper.find(userAvatarLink); + + // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized + const mountComponent = propsData => { + wrapper = shallowMount(boardCard, { + stubs: { + issueCardInner, + }, + store, + propsData: { + list, + issue: list.issues[0], + issueLinkBase: '/', + disabled: false, + index: 0, + rootPath: '/', + ...propsData, + }, + }); + }; + + const setupData = () => { + list = new List(listObj); + boardsStore.create(); + boardsStore.detail.issue = {}; + const label1 = new ListLabel({ + id: 3, + title: 'testing 123', + color: '#000cff', + text_color: 'white', + description: 'test', + }); + return waitForPromises().then(() => { + list.issues[0].labels.push(label1); + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onAny().reply(boardsMockInterceptor); + setMockEndpoints(); + return setupData(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + list = null; + mock.restore(); + }); + + it('when details issue is empty does not show the element', () => { + mountComponent(); + expect(wrapper.classes()).not.toContain('is-active'); + }); + + it('when detailIssue is equal to card issue shows the element', () => { + [boardsStore.detail.issue] = list.issues; + mountComponent(); + + expect(wrapper.classes()).toContain('is-active'); + }); + + it('when multiSelect does not contain issue removes multi select class', () => { + mountComponent(); + expect(wrapper.classes()).not.toContain('multi-select'); + }); + + it('when multiSelect contain issue add multi select class', () => { + boardsStore.multiSelect.list = [list.issues[0]]; + mountComponent(); + + expect(wrapper.classes()).toContain('multi-select'); + }); + + it('adds user-can-drag class if not disabled', () => { + mountComponent(); + expect(wrapper.classes()).toContain('user-can-drag'); + }); + + it('does not add user-can-drag class disabled', () => { + mountComponent({ disabled: true }); + + expect(wrapper.classes()).not.toContain('user-can-drag'); + }); + + it('does not add disabled class', () => { + mountComponent(); + expect(wrapper.classes()).not.toContain('is-disabled'); + }); + + it('adds disabled class is disabled is true', () => { + mountComponent({ disabled: true }); + + expect(wrapper.classes()).toContain('is-disabled'); + }); + + describe('mouse events', () => { + it('sets showDetail to true on mousedown', () => { + mountComponent(); + wrapper.trigger('mousedown'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.showDetail).toBe(true); + }); + }); + + it('sets showDetail to false on mousemove', () => { + mountComponent(); + wrapper.trigger('mousedown'); + return wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.vm.showDetail).toBe(true); + wrapper.trigger('mousemove'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.vm.showDetail).toBe(false); + }); + }); + + it('does not set detail issue if showDetail is false', () => { + mountComponent(); + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if link is clicked', () => { + mountComponent(); + findIssueCardInner() + .find('a') + .trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if img is clicked', () => { + mountComponent({ + issue: { + ...list.issues[0], + assignees: [ + new ListAssignee({ + id: 1, + name: 'testing 123', + username: 'test', + avatar: 'test_image', + }), + ], + }, + }); + + findUserAvatarLink().trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if showDetail is false after mouseup', () => { + mountComponent(); + wrapper.trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('sets detail issue to card issue on mouse up', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + mountComponent(); + + wrapper.trigger('mousedown'); + wrapper.trigger('mouseup'); + + expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, undefined); + expect(boardsStore.detail.list).toEqual(wrapper.vm.list); + }); + + it('resets detail issue to empty if already set', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + [boardsStore.detail.issue] = list.issues; + mountComponent(); + + wrapper.trigger('mousedown'); + wrapper.trigger('mouseup'); + + expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', undefined); + }); + }); +}); diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index b4e1d3b97b1..882310030f8 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -1,11 +1,62 @@ /* global List */ +/* global ListIssue */ import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import eventHub from '~/boards/eventhub'; -import createComponent from './board_list_common_spec'; import waitForPromises from '../helpers/wait_for_promises'; - +import BoardList from '~/boards/components/board_list.vue'; +import '~/boards/models/issue'; import '~/boards/models/list'; +import { listObj, boardsMockInterceptor } from './mock_data'; +import store from '~/boards/stores'; +import boardsStore from '~/boards/stores/boards_store'; + +const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => { + const el = document.createElement('div'); + + document.body.appendChild(el); + const mock = new MockAdapter(axios); + mock.onAny().reply(boardsMockInterceptor); + boardsStore.create(); + + const BoardListComp = Vue.extend(BoardList); + const list = new List({ ...listObj, ...listProps }); + const issue = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [], + assignees: [], + ...listIssueProps, + }); + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { + list.issuesSize = 1; + } + list.issues.push(issue); + + const component = new BoardListComp({ + el, + store, + propsData: { + disabled: false, + list, + issues: list.issues, + loading: false, + issueLinkBase: '/issues', + rootPath: '/', + ...componentProps, + }, + }).$mount(); + + Vue.nextTick(() => { + done(); + }); + + return { component, mock }; +}; describe('Board list component', () => { let mock; @@ -21,7 +72,7 @@ describe('Board list component', () => { describe('When Expanded', () => { beforeEach(done => { - getIssues = spyOn(List.prototype, 'getIssues').and.returnValue(new Promise(() => {})); + getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); ({ mock, component } = createComponent({ done })); }); @@ -30,26 +81,21 @@ describe('Board list component', () => { component.$destroy(); }); - it('loads first page of issues', done => { - waitForPromises() - .then(() => { - expect(getIssues).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + it('loads first page of issues', () => { + return waitForPromises().then(() => { + expect(getIssues).toHaveBeenCalled(); + }); }); it('renders component', () => { expect(component.$el.classList.contains('board-list-component')).toBe(true); }); - it('renders loading icon', done => { + it('renders loading icon', () => { component.loading = true; - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); - - done(); }); }); @@ -61,135 +107,110 @@ describe('Board list component', () => { expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); }); - it('shows new issue form', done => { + it('shows new issue form', () => { component.toggleForm(); - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - - done(); }); }); - it('shows new issue form after eventhub event', done => { + it('shows new issue form after eventhub event', () => { eventHub.$emit(`hide-issue-form-${component.list.id}`); - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - - done(); }); }); - it('does not show new issue form for closed list', done => { + it('does not show new issue form for closed list', () => { component.list.type = 'closed'; component.toggleForm(); - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); - - done(); }); }); - it('shows count list item', done => { + it('shows count list item', () => { component.showCount = true; - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( 'Showing all issues', ); - - done(); }); }); - it('sets data attribute with invalid id', done => { + it('sets data attribute with invalid id', () => { component.showCount = true; - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( '-1', ); - - done(); }); }); - it('shows how many more issues to load', done => { + it('shows how many more issues to load', () => { component.showCount = true; component.list.issuesSize = 20; - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( 'Showing 1 of 20 issues', ); - - done(); }); }); - it('loads more issues after scrolling', done => { - spyOn(component.list, 'nextPage'); - component.$refs.list.style.height = '100px'; - component.$refs.list.style.overflow = 'scroll'; + it('loads more issues after scrolling', () => { + jest.spyOn(component.list, 'nextPage').mockImplementation(() => {}); generateIssues(component); + component.$refs.list.dispatchEvent(new Event('scroll')); - Vue.nextTick(() => { - component.$refs.list.scrollTop = 20000; - - waitForPromises() - .then(() => { - expect(component.list.nextPage).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + return waitForPromises().then(() => { + expect(component.list.nextPage).toHaveBeenCalled(); }); }); - it('does not load issues if already loading', done => { - component.list.nextPage = spyOn(component.list, 'nextPage').and.returnValue( - new Promise(() => {}), - ); + it('does not load issues if already loading', () => { + component.list.nextPage = jest + .spyOn(component.list, 'nextPage') + .mockReturnValue(new Promise(() => {})); component.onScroll(); component.onScroll(); - waitForPromises() - .then(() => { - expect(component.list.nextPage).toHaveBeenCalledTimes(1); - }) - .then(done) - .catch(done.fail); + return waitForPromises().then(() => { + expect(component.list.nextPage).toHaveBeenCalledTimes(1); + }); }); - it('shows loading more spinner', done => { + it('shows loading more spinner', () => { component.showCount = true; component.list.loadingMore = true; - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); - - done(); }); }); }); describe('When Collapsed', () => { beforeEach(done => { - getIssues = spyOn(List.prototype, 'getIssues').and.returnValue(new Promise(() => {})); + getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); ({ mock, component } = createComponent({ done, listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, })); generateIssues(component); - component.scrollHeight = spyOn(component, 'scrollHeight').and.returnValue(0); + component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0); }); afterEach(() => { @@ -197,14 +218,11 @@ describe('Board list component', () => { component.$destroy(); }); - it('does not load all issues', done => { - waitForPromises() - .then(() => { - // Initial getIssues from list constructor - expect(getIssues).toHaveBeenCalledTimes(1); - }) - .then(done) - .catch(done.fail); + it('does not load all issues', () => { + return waitForPromises().then(() => { + // Initial getIssues from list constructor + expect(getIssues).toHaveBeenCalledTimes(1); + }); }); }); @@ -222,39 +240,33 @@ describe('Board list component', () => { }); describe('when issue count exceeds max issue count', () => { - it('sets background to bg-danger-100', done => { + it('sets background to bg-danger-100', () => { component.list.issuesSize = 4; component.list.maxIssueCount = 3; - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull(); - - done(); }); }); }); describe('when list issue count does NOT exceed list max issue count', () => { - it('does not sets background to bg-danger-100', done => { + it('does not sets background to bg-danger-100', () => { component.list.issuesSize = 2; component.list.maxIssueCount = 3; - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - - done(); }); }); }); describe('when list max issue count is 0', () => { - it('does not sets background to bg-danger-100', done => { + it('does not sets background to bg-danger-100', () => { component.list.maxIssueCount = 0; - Vue.nextTick(() => { + return Vue.nextTick().then(() => { expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - - done(); }); }); }); diff --git a/spec/javascripts/boards/list_spec.js b/spec/frontend/boards/list_spec.js index 7385bfb0e5f..c0dd5afe498 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/frontend/boards/list_spec.js @@ -9,7 +9,9 @@ import '~/boards/models/label'; import '~/boards/models/assignee'; import '~/boards/models/issue'; import '~/boards/models/list'; +import { ListType } from '~/boards/constants'; import boardsStore from '~/boards/stores/boards_store'; +import waitForPromises from 'helpers/wait_for_promises'; import { listObj, listObjDuplicate, boardsMockInterceptor } from './mock_data'; describe('List model', () => { @@ -20,22 +22,35 @@ describe('List model', () => { mock = new MockAdapter(axios); mock.onAny().reply(boardsMockInterceptor); boardsStore.create(); + boardsStore.setEndpoints({ + listsEndpoint: '/test/-/boards/1/lists', + }); list = new List(listObj); + return waitForPromises(); }); afterEach(() => { mock.restore(); }); - it('gets issues when created', done => { - setTimeout(() => { - expect(list.issues.length).toBe(1); - done(); - }, 0); + describe('list type', () => { + const notExpandableList = ['blank']; + + const table = Object.keys(ListType).map(k => { + const value = ListType[k]; + return [value, !notExpandableList.includes(value)]; + }); + it.each(table)(`when list_type is %s boards isExpandable is %p`, (type, result) => { + expect(new List({ id: 1, list_type: type }).isExpandable).toBe(result); + }); }); - it('saves list and returns ID', done => { + it('gets issues when created', () => { + expect(list.issues.length).toBe(1); + }); + + it('saves list and returns ID', () => { list = new List({ title: 'test', label: { @@ -45,50 +60,40 @@ describe('List model', () => { text_color: 'white', }, }); - list.save(); - - setTimeout(() => { + return list.save().then(() => { expect(list.id).toBe(listObj.id); expect(list.type).toBe('label'); expect(list.position).toBe(0); expect(list.label.color).toBe('red'); expect(list.label.textColor).toBe('white'); - done(); - }, 0); + }); }); - it('destroys the list', done => { + it('destroys the list', () => { boardsStore.addList(listObj); list = boardsStore.findList('id', listObj.id); expect(boardsStore.state.lists.length).toBe(1); list.destroy(); - setTimeout(() => { + return waitForPromises().then(() => { expect(boardsStore.state.lists.length).toBe(0); - done(); - }, 0); + }); }); - it('gets issue from list', done => { - setTimeout(() => { - const issue = list.findIssue(1); + it('gets issue from list', () => { + const issue = list.findIssue(1); - expect(issue).toBeDefined(); - done(); - }, 0); + expect(issue).toBeDefined(); }); - it('removes issue', done => { - setTimeout(() => { - const issue = list.findIssue(1); + it('removes issue', () => { + const issue = list.findIssue(1); - expect(list.issues.length).toBe(1); - list.removeIssue(issue); + expect(list.issues.length).toBe(1); + list.removeIssue(issue); - expect(list.issues.length).toBe(0); - done(); - }, 0); + expect(list.issues.length).toBe(0); }); it('sends service request to update issue label', () => { @@ -105,7 +110,7 @@ describe('List model', () => { list.issues.push(issue); listDup.issues.push(issue); - spyOn(boardsStore, 'moveIssue').and.callThrough(); + jest.spyOn(boardsStore, 'moveIssue'); listDup.updateIssueLabel(issue, list); @@ -120,7 +125,8 @@ describe('List model', () => { describe('page number', () => { beforeEach(() => { - spyOn(list, 'getIssues'); + jest.spyOn(list, 'getIssues').mockImplementation(() => {}); + list.issues = []; }); it('increase page number if current issue count is more than the page size', () => { @@ -167,7 +173,7 @@ describe('List model', () => { describe('newIssue', () => { beforeEach(() => { - spyOn(boardsStore, 'newIssue').and.returnValue( + jest.spyOn(boardsStore, 'newIssue').mockReturnValue( Promise.resolve({ data: { id: 42, @@ -178,6 +184,7 @@ describe('List model', () => { }, }), ); + list.issues = []; }); it('adds new issue to top of list', done => { diff --git a/spec/frontend/registry/list/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/registry/list/components/__snapshots__/group_empty_state_spec.js.snap deleted file mode 100644 index 3f13b7d4d76..00000000000 --- a/spec/frontend/registry/list/components/__snapshots__/group_empty_state_spec.js.snap +++ /dev/null @@ -1,61 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Registry Group Empty state to match the default snapshot 1`] = ` -<div - class="row container-message empty-state" -> - <div - class="col-12" - > - <div - class="svg-250 svg-content" - > - <img - alt="There are no container images available in this group" - class="" - src="imageUrl" - /> - </div> - </div> - - <div - class="col-12" - > - <div - class="text-content" - > - <h4 - class="center" - style="" - > - There are no container images available in this group - </h4> - - <p - class="center" - style="" - > - <p - class="js-no-container-images-text" - > - With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. - <a - href="help" - target="_blank" - > - More Information - </a> - </p> - </p> - - <div - class="text-center" - > - <!----> - - <!----> - </div> - </div> - </div> -</div> -`; diff --git a/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap deleted file mode 100644 index c072950f3e2..00000000000 --- a/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap +++ /dev/null @@ -1,186 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Registry Project Empty state to match the default snapshot 1`] = ` -<div - class="row container-message empty-state" -> - <div - class="col-12" - > - <div - class="svg-250 svg-content" - > - <img - alt="There are no container images stored for this project" - class="" - src="imageUrl" - /> - </div> - </div> - - <div - class="col-12" - > - <div - class="text-content" - > - <h4 - class="center" - style="" - > - There are no container images stored for this project - </h4> - - <p - class="center" - style="" - > - <p - class="js-no-container-images-text" - > - With the Container Registry, every project can have its own space to store its Docker images. - <a - href="help" - target="_blank" - > - More Information - </a> - </p> - - <h5> - Quick Start - </h5> - - <p - class="js-not-logged-in-to-registry-text" - > - If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have - <a - href="help_link" - target="_blank" - > - Two-Factor Authentication - </a> - enabled, use a - <a - href="personal_token" - target="_blank" - > - Personal Access Token - </a> - instead of a password. - </p> - - <div - class="input-group append-bottom-10" - > - <input - class="form-control monospace" - readonly="readonly" - type="text" - /> - - <span - class="input-group-append" - > - <button - class="btn input-group-text btn-secondary btn-md btn-default" - data-clipboard-text="docker login host" - title="Copy login command" - type="button" - > - <!----> - - <svg - class="gl-icon s16" - > - <use - href="#copy-to-clipboard" - /> - </svg> - </button> - </span> - </div> - - <p /> - - <p> - - You can add an image to this registry with the following commands: - - </p> - - <div - class="input-group append-bottom-10" - > - <input - class="form-control monospace" - readonly="readonly" - type="text" - /> - - <span - class="input-group-append" - > - <button - class="btn input-group-text btn-secondary btn-md btn-default" - data-clipboard-text="docker build -t url ." - title="Copy build command" - type="button" - > - <!----> - - <svg - class="gl-icon s16" - > - <use - href="#copy-to-clipboard" - /> - </svg> - </button> - </span> - </div> - - <div - class="input-group" - > - <input - class="form-control monospace" - readonly="readonly" - type="text" - /> - - <span - class="input-group-append" - > - <button - class="btn input-group-text btn-secondary btn-md btn-default" - data-clipboard-text="docker push url" - title="Copy push command" - type="button" - > - <!----> - - <svg - class="gl-icon s16" - > - <use - href="#copy-to-clipboard" - /> - </svg> - </button> - </span> - </div> - </p> - - <div - class="text-center" - > - <!----> - - <!----> - </div> - </div> - </div> -</div> -`; diff --git a/spec/frontend/registry/list/components/app_spec.js b/spec/frontend/registry/list/components/app_spec.js deleted file mode 100644 index c2c220b2cd2..00000000000 --- a/spec/frontend/registry/list/components/app_spec.js +++ /dev/null @@ -1,149 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { TEST_HOST } from 'helpers/test_constants'; -import registry from '~/registry/list/components/app.vue'; -import { reposServerResponse, parsedReposServerResponse } from '../mock_data'; - -describe('Registry List', () => { - let wrapper; - - const findCollapsibleContainer = () => wrapper.findAll({ name: 'CollapsibeContainerRegisty' }); - const findProjectEmptyState = () => wrapper.find({ name: 'ProjectEmptyState' }); - const findGroupEmptyState = () => wrapper.find({ name: 'GroupEmptyState' }); - const findSpinner = () => wrapper.find('.gl-spinner'); - const findCharacterErrorText = () => wrapper.find('.js-character-error-text'); - - const propsData = { - endpoint: `${TEST_HOST}/foo`, - helpPagePath: 'foo', - noContainersImage: 'foo', - containersErrorImage: 'foo', - repositoryUrl: 'foo', - registryHostUrlWithPort: 'foo', - personalAccessTokensHelpLink: 'foo', - twoFactorAuthHelpLink: 'foo', - }; - - const setMainEndpoint = jest.fn(); - const fetchRepos = jest.fn(); - const setIsDeleteDisabled = jest.fn(); - - const methods = { - setMainEndpoint, - fetchRepos, - setIsDeleteDisabled, - }; - - beforeEach(() => { - wrapper = mount(registry, { - propsData, - computed: { - repos() { - return parsedReposServerResponse; - }, - }, - methods, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('with data', () => { - it('should render a list of CollapsibeContainerRegisty', () => { - const containers = findCollapsibleContainer(); - expect(wrapper.vm.repos.length).toEqual(reposServerResponse.length); - expect(containers.length).toEqual(reposServerResponse.length); - }); - }); - - describe('without data', () => { - beforeEach(() => { - wrapper = mount(registry, { - propsData, - computed: { - repos() { - return []; - }, - }, - methods, - }); - }); - - it('should render project empty message', () => { - const projectEmptyState = findProjectEmptyState(); - expect(projectEmptyState.exists()).toBe(true); - }); - }); - - describe('while loading data', () => { - beforeEach(() => { - wrapper = mount(registry, { - propsData, - computed: { - repos() { - return []; - }, - isLoading() { - return true; - }, - }, - methods, - }); - }); - - it('should render a loading spinner', () => { - const spinner = findSpinner(); - expect(spinner.exists()).toBe(true); - }); - }); - - describe('invalid characters in path', () => { - beforeEach(() => { - wrapper = mount(registry, { - propsData: { - ...propsData, - characterError: true, - }, - computed: { - repos() { - return []; - }, - }, - methods, - }); - }); - - it('should render invalid characters error message', () => { - const characterErrorText = findCharacterErrorText(); - expect(characterErrorText.text()).toEqual( - 'We are having trouble connecting to Docker, which could be due to an issue with your project name or path. More Information', - ); - }); - }); - - describe('with groupId set', () => { - const isGroupPage = true; - - beforeEach(() => { - wrapper = mount(registry, { - propsData: { - ...propsData, - endpoint: '', - isGroupPage, - }, - methods, - }); - }); - - it('call the right vuex setters', () => { - expect(methods.setMainEndpoint).toHaveBeenLastCalledWith(''); - expect(methods.setIsDeleteDisabled).toHaveBeenLastCalledWith(true); - }); - - it('should render groups empty message', () => { - const groupEmptyState = findGroupEmptyState(wrapper); - expect(groupEmptyState.exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/registry/list/components/collapsible_container_spec.js b/spec/frontend/registry/list/components/collapsible_container_spec.js deleted file mode 100644 index f969f0ba9ba..00000000000 --- a/spec/frontend/registry/list/components/collapsible_container_spec.js +++ /dev/null @@ -1,176 +0,0 @@ -import Vuex from 'vuex'; -import { mount, createLocalVue } from '@vue/test-utils'; -import createFlash from '~/flash'; -import Tracking from '~/tracking'; -import collapsibleComponent from '~/registry/list/components/collapsible_container.vue'; -import * as getters from '~/registry/list/stores/getters'; -import { repoPropsData } from '../mock_data'; - -jest.mock('~/flash.js'); - -const localVue = createLocalVue(); - -localVue.use(Vuex); - -describe('collapsible registry container', () => { - let wrapper; - let store; - - const findDeleteBtn = () => wrapper.find('.js-remove-repo'); - const findContainerImageTags = () => wrapper.find('.container-image-tags'); - const findToggleRepos = () => wrapper.findAll('.js-toggle-repo'); - const findDeleteModal = () => wrapper.find({ ref: 'deleteModal' }); - - const mountWithStore = config => - mount(collapsibleComponent, { - ...config, - store, - localVue, - }); - - beforeEach(() => { - createFlash.mockClear(); - store = new Vuex.Store({ - state: { - isDeleteDisabled: false, - }, - getters, - }); - - wrapper = mountWithStore({ - propsData: { - repo: repoPropsData, - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('toggle', () => { - beforeEach(() => { - const fetchList = jest.fn(); - wrapper.setMethods({ fetchList }); - return wrapper.vm.$nextTick(); - }); - - const expectIsClosed = () => { - const container = findContainerImageTags(); - expect(container.exists()).toBe(false); - expect(wrapper.vm.iconName).toEqual('angle-right'); - }; - - it('should be closed by default', () => { - expectIsClosed(); - }); - - it('should be open when user clicks on closed repo', () => { - const toggleRepos = findToggleRepos(); - toggleRepos.at(0).trigger('click'); - return wrapper.vm.$nextTick().then(() => { - const container = findContainerImageTags(); - expect(container.exists()).toBe(true); - expect(wrapper.vm.fetchList).toHaveBeenCalled(); - }); - }); - - it('should be closed when the user clicks on an opened repo', () => { - const toggleRepos = findToggleRepos(); - toggleRepos.at(0).trigger('click'); - return wrapper.vm.$nextTick().then(() => { - toggleRepos.at(0).trigger('click'); - wrapper.vm.$nextTick(() => { - expectIsClosed(); - }); - }); - }); - }); - - describe('delete repo', () => { - beforeEach(() => { - const deleteItem = jest.fn().mockResolvedValue(); - const fetchRepos = jest.fn().mockResolvedValue(); - wrapper.setMethods({ deleteItem, fetchRepos }); - }); - - it('should be possible to delete a repo', () => { - const deleteBtn = findDeleteBtn(); - expect(deleteBtn.exists()).toBe(true); - }); - - it('should call deleteItem when confirming deletion', () => { - wrapper.vm.handleDeleteRepository(); - expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(wrapper.vm.repo); - }); - - it('should show a flash with a success notice', () => - wrapper.vm.handleDeleteRepository().then(() => { - expect(wrapper.vm.deleteImageConfirmationMessage).toContain(wrapper.vm.repo.name); - expect(createFlash).toHaveBeenCalledWith( - wrapper.vm.deleteImageConfirmationMessage, - 'notice', - ); - })); - - it('should show an error when there is API error', () => { - const deleteItem = jest.fn().mockRejectedValue('error'); - wrapper.setMethods({ deleteItem }); - return wrapper.vm.handleDeleteRepository().then(() => { - expect(createFlash).toHaveBeenCalled(); - }); - }); - }); - - describe('disabled delete', () => { - beforeEach(() => { - store = new Vuex.Store({ - state: { - isDeleteDisabled: true, - }, - getters, - }); - wrapper = mountWithStore({ - propsData: { - repo: repoPropsData, - }, - }); - }); - - it('should not render delete button', () => { - const deleteBtn = findDeleteBtn(); - expect(deleteBtn.exists()).toBe(false); - }); - }); - - describe('tracking', () => { - const testTrackingCall = action => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { - label: 'registry_repository_delete', - }); - }; - - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - wrapper.vm.deleteItem = jest.fn().mockResolvedValue(); - wrapper.vm.fetchRepos = jest.fn(); - }); - - it('send an event when delete button is clicked', () => { - const deleteBtn = findDeleteBtn(); - deleteBtn.trigger('click'); - testTrackingCall('click_button'); - }); - it('send an event when cancel is pressed on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('cancel'); - testTrackingCall('cancel_delete'); - }); - it('send an event when confirm is clicked on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('ok'); - - testTrackingCall('confirm_delete'); - }); - }); -}); diff --git a/spec/frontend/registry/list/components/group_empty_state_spec.js b/spec/frontend/registry/list/components/group_empty_state_spec.js deleted file mode 100644 index 7541c3d459c..00000000000 --- a/spec/frontend/registry/list/components/group_empty_state_spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import { mount } from '@vue/test-utils'; -import groupEmptyState from '~/registry/list/components/group_empty_state.vue'; - -describe('Registry Group Empty state', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount(groupEmptyState, { - propsData: { - noContainersImage: 'imageUrl', - helpPagePath: 'help', - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('to match the default snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); -}); diff --git a/spec/frontend/registry/list/components/project_empty_state_spec.js b/spec/frontend/registry/list/components/project_empty_state_spec.js deleted file mode 100644 index d29b9e47233..00000000000 --- a/spec/frontend/registry/list/components/project_empty_state_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import { mount } from '@vue/test-utils'; -import projectEmptyState from '~/registry/list/components/project_empty_state.vue'; - -describe('Registry Project Empty state', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount(projectEmptyState, { - propsData: { - noContainersImage: 'imageUrl', - helpPagePath: 'help', - repositoryUrl: 'url', - twoFactorAuthHelpLink: 'help_link', - personalAccessTokensHelpLink: 'personal_token', - registryHostUrlWithPort: 'host', - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('to match the default snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); -}); diff --git a/spec/frontend/registry/list/components/table_registry_spec.js b/spec/frontend/registry/list/components/table_registry_spec.js deleted file mode 100644 index b13797929dd..00000000000 --- a/spec/frontend/registry/list/components/table_registry_spec.js +++ /dev/null @@ -1,373 +0,0 @@ -import Vuex from 'vuex'; -import { mount, createLocalVue } from '@vue/test-utils'; -import createFlash from '~/flash'; -import Tracking from '~/tracking'; -import tableRegistry from '~/registry/list/components/table_registry.vue'; -import { repoPropsData } from '../mock_data'; -import * as getters from '~/registry/list/stores/getters'; - -jest.mock('~/flash'); - -const [firstImage, secondImage] = repoPropsData.list; - -const localVue = createLocalVue(); - -localVue.use(Vuex); - -describe('table registry', () => { - let wrapper; - let store; - - const findSelectAllCheckbox = () => wrapper.find('.js-select-all-checkbox > input'); - const findSelectCheckboxes = () => wrapper.findAll('.js-select-checkbox > input'); - const findDeleteButton = () => wrapper.find({ ref: 'bulkDeleteButton' }); - const findDeleteButtonsRow = () => wrapper.findAll('.js-delete-registry-row'); - const findPagination = () => wrapper.find('.js-registry-pagination'); - const findDeleteModal = () => wrapper.find({ ref: 'deleteModal' }); - const findImageId = () => wrapper.find({ ref: 'imageId' }); - const bulkDeletePath = 'path'; - - const mountWithStore = config => - mount(tableRegistry, { - ...config, - store, - localVue, - }); - - beforeEach(() => { - store = new Vuex.Store({ - state: { - isDeleteDisabled: false, - }, - getters, - }); - - wrapper = mountWithStore({ - propsData: { - repo: repoPropsData, - canDeleteRepo: true, - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('rendering', () => { - it('should render a table with the registry list', () => { - expect(wrapper.findAll('.registry-image-row').length).toEqual(repoPropsData.list.length); - }); - - it('should render registry tag', () => { - const tds = wrapper.findAll('.registry-image-row td'); - expect(tds.at(0).classes()).toContain('check'); - expect(tds.at(1).html()).toContain(repoPropsData.list[0].tag); - expect(tds.at(2).html()).toContain(repoPropsData.list[0].shortRevision); - expect(tds.at(3).html()).toContain(repoPropsData.list[0].layers); - expect(tds.at(3).html()).toContain(repoPropsData.list[0].size); - expect(tds.at(4).html()).toContain(wrapper.vm.timeFormatted(repoPropsData.list[0].createdAt)); - }); - - it('should have a label called Image ID', () => { - const label = findImageId(); - expect(label.element).toMatchInlineSnapshot(` - <th> - Image ID - </th> - `); - }); - }); - - describe('multi select', () => { - it('selecting a row should enable delete button', () => { - const deleteBtn = findDeleteButton(); - const checkboxes = findSelectCheckboxes(); - - expect(deleteBtn.attributes('disabled')).toBe('disabled'); - - checkboxes.at(0).trigger('click'); - return wrapper.vm.$nextTick().then(() => { - expect(deleteBtn.attributes('disabled')).toEqual(undefined); - }); - }); - - it('selecting all checkbox should select all rows and enable delete button', () => { - const selectAll = findSelectAllCheckbox(); - selectAll.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - const checkboxes = findSelectCheckboxes(); - const checked = checkboxes.filter(w => w.element.checked); - expect(checked.length).toBe(checkboxes.length); - }); - }); - - it('deselecting select all checkbox should deselect all rows and disable delete button', () => { - const checkboxes = findSelectCheckboxes(); - const selectAll = findSelectAllCheckbox(); - selectAll.trigger('click'); - selectAll.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - const checked = checkboxes.filter(w => !w.element.checked); - expect(checked.length).toBe(checkboxes.length); - }); - }); - - it('should delete multiple items when multiple items are selected', () => { - const multiDeleteItems = jest.fn().mockResolvedValue(); - wrapper.setMethods({ multiDeleteItems }); - - return wrapper.vm - .$nextTick() - .then(() => { - const selectAll = findSelectAllCheckbox(); - selectAll.trigger('click'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - const deleteBtn = findDeleteButton(); - expect(wrapper.vm.selectedItems).toEqual([0, 1]); - expect(deleteBtn.attributes('disabled')).toEqual(undefined); - wrapper.setData({ itemsToBeDeleted: [...wrapper.vm.selectedItems] }); - wrapper.vm.handleMultipleDelete(); - expect(wrapper.vm.selectedItems).toEqual([]); - expect(wrapper.vm.itemsToBeDeleted).toEqual([]); - expect(wrapper.vm.multiDeleteItems).toHaveBeenCalledWith({ - path: bulkDeletePath, - items: [firstImage.tag, secondImage.tag], - }); - }); - }); - - it('should show an error message if bulkDeletePath is not set', () => { - const showError = jest.fn(); - wrapper.setMethods({ showError }); - wrapper.setProps({ - repo: { - ...repoPropsData, - tagsPath: null, - }, - }); - wrapper.vm.handleMultipleDelete(); - expect(createFlash).toHaveBeenCalled(); - }); - }); - - describe('delete registry', () => { - beforeEach(() => { - wrapper.setData({ selectedItems: [0] }); - return wrapper.vm.$nextTick(); - }); - - it('should be possible to delete a registry', () => { - const deleteBtn = findDeleteButton(); - const deleteBtns = findDeleteButtonsRow(); - expect(wrapper.vm.selectedItems).toEqual([0]); - expect(deleteBtn).toBeDefined(); - expect(deleteBtn.attributes('disable')).toBe(undefined); - expect(deleteBtns.is('button')).toBe(true); - }); - - it('should allow deletion row by row', () => { - const deleteBtns = findDeleteButtonsRow(); - const deleteSingleItem = jest.fn(); - const deleteItem = jest.fn().mockResolvedValue(); - wrapper.setMethods({ deleteSingleItem, deleteItem }); - return wrapper.vm.$nextTick().then(() => { - deleteBtns.at(0).trigger('click'); - expect(wrapper.vm.deleteSingleItem).toHaveBeenCalledWith(0); - wrapper.vm.handleSingleDelete(1); - expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(1); - }); - }); - }); - - describe('modal event handlers', () => { - beforeEach(() => { - wrapper.vm.handleSingleDelete = jest.fn(); - wrapper.vm.handleMultipleDelete = jest.fn(); - }); - it('on ok when one item is selected should call singleDelete', () => { - wrapper.setData({ itemsToBeDeleted: [0] }); - wrapper.vm.onDeletionConfirmed(); - - expect(wrapper.vm.handleSingleDelete).toHaveBeenCalledWith(repoPropsData.list[0]); - expect(wrapper.vm.handleMultipleDelete).not.toHaveBeenCalled(); - }); - it('on ok when multiple items are selected should call multiDelete', () => { - wrapper.setData({ itemsToBeDeleted: [0, 1, 2] }); - wrapper.vm.onDeletionConfirmed(); - - expect(wrapper.vm.handleMultipleDelete).toHaveBeenCalled(); - expect(wrapper.vm.handleSingleDelete).not.toHaveBeenCalled(); - }); - }); - - describe('pagination', () => { - const repo = { - repoPropsData, - pagination: { - total: 20, - perPage: 2, - nextPage: 2, - }, - }; - - beforeEach(() => { - wrapper = mount(tableRegistry, { - propsData: { - repo, - }, - }); - }); - - it('should exist', () => { - const pagination = findPagination(); - expect(pagination.exists()).toBe(true); - }); - it('should be visible when pagination is needed', () => { - const pagination = findPagination(); - expect(pagination.isVisible()).toBe(true); - wrapper.setProps({ - repo: { - pagination: { - total: 0, - perPage: 10, - }, - }, - }); - expect(wrapper.vm.shouldRenderPagination).toBe(false); - }); - it('should have a change function that update the list when run', () => { - const fetchList = jest.fn().mockResolvedValue(); - wrapper.setMethods({ fetchList }); - wrapper.vm.onPageChange(1); - expect(wrapper.vm.fetchList).toHaveBeenCalledWith({ repo, page: 1 }); - }); - }); - - describe('modal content', () => { - it('should show the singular title and image name when deleting a single image', () => { - wrapper.setData({ selectedItems: [1, 2, 3] }); - wrapper.vm.deleteSingleItem(0); - expect(wrapper.vm.modalAction).toBe('Remove tag'); - expect(wrapper.vm.modalDescription).toContain(firstImage.tag); - }); - - it('should show the plural title and image count when deleting more than one image', () => { - wrapper.setData({ selectedItems: [1, 2] }); - wrapper.vm.deleteMultipleItems(); - - expect(wrapper.vm.modalAction).toBe('Remove tags'); - expect(wrapper.vm.modalDescription).toContain('<b>2</b> tags'); - }); - }); - - describe('disabled delete', () => { - beforeEach(() => { - store = new Vuex.Store({ - state: { - isDeleteDisabled: true, - }, - getters, - }); - wrapper = mountWithStore({ - propsData: { - repo: repoPropsData, - canDeleteRepo: false, - }, - }); - }); - - it('should not render select all', () => { - const selectAll = findSelectAllCheckbox(); - expect(selectAll.exists()).toBe(false); - }); - - it('should not render any select checkbox', () => { - const selects = findSelectCheckboxes(); - expect(selects.length).toBe(0); - }); - - it('should not render delete registry button', () => { - const deleteBtn = findDeleteButton(); - expect(deleteBtn.exists()).toBe(false); - }); - - it('should not render delete row button', () => { - const deleteBtns = findDeleteButtonsRow(); - expect(deleteBtns.length).toBe(0); - }); - }); - - describe('event tracking', () => { - const testTrackingCall = (action, label = 'registry_tag_delete') => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { label, property: 'foo' }); - }; - - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - wrapper.vm.handleSingleDelete = jest.fn(); - wrapper.vm.handleMultipleDelete = jest.fn(); - }); - - describe('single tag delete', () => { - beforeEach(() => { - wrapper.setData({ itemsToBeDeleted: [0] }); - return wrapper.vm.$nextTick(); - }); - - it('send an event when delete button is clicked', () => { - const deleteBtn = findDeleteButtonsRow(); - deleteBtn.at(0).trigger('click'); - - testTrackingCall('click_button'); - }); - - it('send an event when cancel is pressed on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('cancel'); - - testTrackingCall('cancel_delete'); - }); - - it('send an event when confirm is clicked on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('ok'); - - testTrackingCall('confirm_delete'); - }); - }); - - describe('bulk tag delete', () => { - beforeEach(() => { - const items = [0, 1, 2]; - wrapper.setData({ itemsToBeDeleted: items, selectedItems: items }); - return wrapper.vm.$nextTick(); - }); - - it('send an event when delete button is clicked', () => { - const deleteBtn = findDeleteButton(); - deleteBtn.vm.$emit('click'); - - testTrackingCall('click_button', 'bulk_registry_tag_delete'); - }); - - it('send an event when cancel is pressed on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('cancel'); - - testTrackingCall('cancel_delete', 'bulk_registry_tag_delete'); - }); - - it('send an event when confirm is clicked on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('ok'); - - testTrackingCall('confirm_delete', 'bulk_registry_tag_delete'); - }); - }); - }); -}); diff --git a/spec/frontend/registry/list/mock_data.js b/spec/frontend/registry/list/mock_data.js deleted file mode 100644 index 130ab298e89..00000000000 --- a/spec/frontend/registry/list/mock_data.js +++ /dev/null @@ -1,134 +0,0 @@ -export const defaultState = { - isLoading: false, - endpoint: '', - repos: [], -}; - -export const reposServerResponse = [ - { - destroy_path: 'path', - id: '123', - location: 'location', - path: 'foo', - tags_path: 'tags_path', - }, - { - destroy_path: 'path_', - id: '456', - location: 'location_', - path: 'bar', - tags_path: 'tags_path_', - }, -]; - -export const registryServerResponse = [ - { - name: 'centos7', - short_revision: 'b118ab5b0', - revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', - total_size: 679, - layers: 19, - location: 'location', - created_at: 1505828744434, - destroy_path: 'path_', - }, - { - name: 'centos6', - short_revision: 'b118ab5b0', - revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', - total_size: 679, - layers: 19, - location: 'location', - created_at: 1505828744434, - }, -]; - -export const parsedReposServerResponse = [ - { - canDelete: true, - destroyPath: reposServerResponse[0].destroy_path, - id: reposServerResponse[0].id, - isLoading: false, - list: [], - location: reposServerResponse[0].location, - name: reposServerResponse[0].path, - tagsPath: reposServerResponse[0].tags_path, - }, - { - canDelete: true, - destroyPath: reposServerResponse[1].destroy_path, - id: reposServerResponse[1].id, - isLoading: false, - list: [], - location: reposServerResponse[1].location, - name: reposServerResponse[1].path, - tagsPath: reposServerResponse[1].tags_path, - }, -]; - -export const parsedRegistryServerResponse = [ - { - tag: registryServerResponse[0].name, - revision: registryServerResponse[0].revision, - shortRevision: registryServerResponse[0].short_revision, - size: registryServerResponse[0].total_size, - layers: registryServerResponse[0].layers, - location: registryServerResponse[0].location, - createdAt: registryServerResponse[0].created_at, - destroyPath: registryServerResponse[0].destroy_path, - canDelete: true, - }, - { - tag: registryServerResponse[1].name, - revision: registryServerResponse[1].revision, - shortRevision: registryServerResponse[1].short_revision, - size: registryServerResponse[1].total_size, - layers: registryServerResponse[1].layers, - location: registryServerResponse[1].location, - createdAt: registryServerResponse[1].created_at, - destroyPath: registryServerResponse[1].destroy_path, - canDelete: false, - }, -]; - -export const repoPropsData = { - canDelete: true, - destroyPath: 'path', - id: '123', - isLoading: false, - list: [ - { - tag: 'centos6', - revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', - shortRevision: 'b118ab5b0', - size: 19, - layers: 10, - location: 'location', - createdAt: 1505828744434, - destroyPath: 'path', - canDelete: true, - }, - { - tag: 'test-image', - revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4', - shortRevision: 'b969de599', - size: 19, - layers: 10, - location: 'location-2', - createdAt: 1505828744434, - destroyPath: 'path-2', - canDelete: true, - }, - ], - location: 'location', - name: 'foo', - tagsPath: 'path', - pagination: { - perPage: 5, - page: 1, - total: 13, - totalPages: 1, - nextPage: null, - previousPage: null, - }, -}; diff --git a/spec/frontend/registry/list/stores/actions_spec.js b/spec/frontend/registry/list/stores/actions_spec.js deleted file mode 100644 index 2fc363e9a4f..00000000000 --- a/spec/frontend/registry/list/stores/actions_spec.js +++ /dev/null @@ -1,203 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { TEST_HOST } from 'helpers/test_constants'; -import testAction from 'helpers/vuex_action_helper'; -import axios from '~/lib/utils/axios_utils'; -import * as actions from '~/registry/list/stores/actions'; -import * as types from '~/registry/list/stores/mutation_types'; -import createFlash from '~/flash'; - -import { - reposServerResponse, - registryServerResponse, - parsedReposServerResponse, -} from '../mock_data'; - -jest.mock('~/flash.js'); - -describe('Actions Registry Store', () => { - let mock; - let state; - - beforeEach(() => { - mock = new MockAdapter(axios); - state = { - endpoint: `${TEST_HOST}/endpoint.json`, - }; - }); - - afterEach(() => { - mock.restore(); - }); - - describe('fetchRepos', () => { - beforeEach(() => { - mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {}); - }); - - it('should set received repos', done => { - testAction( - actions.fetchRepos, - null, - state, - [ - { type: types.TOGGLE_MAIN_LOADING }, - { type: types.TOGGLE_MAIN_LOADING }, - { type: types.SET_REPOS_LIST, payload: reposServerResponse }, - ], - [], - done, - ); - }); - - it('should create flash on API error', done => { - testAction( - actions.fetchRepos, - null, - { - endpoint: null, - }, - [{ type: types.TOGGLE_MAIN_LOADING }, { type: types.TOGGLE_MAIN_LOADING }], - [], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, - ); - }); - }); - - describe('fetchList', () => { - let repo; - beforeEach(() => { - state.repos = parsedReposServerResponse; - [, repo] = state.repos; - }); - - it('should set received list', done => { - mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {}); - testAction( - actions.fetchList, - { repo }, - state, - [ - { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo }, - { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo }, - { - type: types.SET_REGISTRY_LIST, - payload: { - repo, - resp: registryServerResponse, - headers: expect.anything(), - }, - }, - ], - [], - done, - ); - }); - - it('should create flash on API error', done => { - mock.onGet(repo.tagsPath).replyOnce(400); - const updatedRepo = { - ...repo, - tagsPath: null, - }; - testAction( - actions.fetchList, - { - repo: updatedRepo, - }, - state, - [ - { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo }, - { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo }, - ], - [], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, - ); - }); - }); - - describe('setMainEndpoint', () => { - it('should commit set main endpoint', done => { - testAction( - actions.setMainEndpoint, - 'endpoint', - state, - [{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }], - [], - done, - ); - }); - }); - - describe('setIsDeleteDisabled', () => { - it('should commit set is delete disabled', done => { - testAction( - actions.setIsDeleteDisabled, - true, - state, - [{ type: types.SET_IS_DELETE_DISABLED, payload: true }], - [], - done, - ); - }); - }); - - describe('toggleLoading', () => { - it('should commit toggle main loading', done => { - testAction( - actions.toggleLoading, - null, - state, - [{ type: types.TOGGLE_MAIN_LOADING }], - [], - done, - ); - }); - }); - - describe('deleteItem and multiDeleteItems', () => { - let deleted; - const destroyPath = `${TEST_HOST}/mygroup/myproject/container_registry/1.json`; - - const expectDelete = done => { - expect(mock.history.delete.length).toBe(1); - expect(deleted).toBe(true); - done(); - }; - - beforeEach(() => { - deleted = false; - mock.onDelete(destroyPath).replyOnce(() => { - deleted = true; - return [200]; - }); - }); - - it('deleteItem should perform DELETE request on destroyPath', done => { - testAction( - actions.deleteItem, - { - destroyPath, - }, - state, - ) - .then(() => { - expectDelete(done); - }) - .catch(done.fail); - }); - - it('multiDeleteItems should perform DELETE request on path', done => { - testAction(actions.multiDeleteItems, { path: destroyPath, items: [1] }, state) - .then(() => { - expectDelete(done); - }) - .catch(done.fail); - }); - }); -}); diff --git a/spec/frontend/registry/list/stores/getters_spec.js b/spec/frontend/registry/list/stores/getters_spec.js deleted file mode 100644 index c8d054b226b..00000000000 --- a/spec/frontend/registry/list/stores/getters_spec.js +++ /dev/null @@ -1,52 +0,0 @@ -import * as getters from '~/registry/list/stores/getters'; - -describe('Getters Registry Store', () => { - let state; - - beforeEach(() => { - state = { - isLoading: false, - endpoint: '/root/empty-project/container_registry.json', - isDeleteDisabled: false, - repos: [ - { - canDelete: true, - destroyPath: 'bar', - id: '134', - isLoading: false, - list: [], - location: 'foo', - name: 'gitlab-org/omnibus-gitlab/foo', - tagsPath: 'foo', - }, - { - canDelete: true, - destroyPath: 'bar', - id: '123', - isLoading: false, - list: [], - location: 'foo', - name: 'gitlab-org/omnibus-gitlab', - tagsPath: 'foo', - }, - ], - }; - }); - - describe('isLoading', () => { - it('should return the isLoading property', () => { - expect(getters.isLoading(state)).toEqual(state.isLoading); - }); - }); - - describe('repos', () => { - it('should return the repos', () => { - expect(getters.repos(state)).toEqual(state.repos); - }); - }); - describe('isDeleteDisabled', () => { - it('should return isDeleteDisabled', () => { - expect(getters.isDeleteDisabled(state)).toEqual(state.isDeleteDisabled); - }); - }); -}); diff --git a/spec/frontend/registry/list/stores/mutations_spec.js b/spec/frontend/registry/list/stores/mutations_spec.js deleted file mode 100644 index f894f688c1f..00000000000 --- a/spec/frontend/registry/list/stores/mutations_spec.js +++ /dev/null @@ -1,94 +0,0 @@ -import mutations from '~/registry/list/stores/mutations'; -import * as types from '~/registry/list/stores/mutation_types'; -import { - defaultState, - reposServerResponse, - registryServerResponse, - parsedReposServerResponse, - parsedRegistryServerResponse, -} from '../mock_data'; - -describe('Mutations Registry Store', () => { - let mockState; - beforeEach(() => { - mockState = defaultState; - }); - - describe('SET_MAIN_ENDPOINT', () => { - it('should set the main endpoint', () => { - const expectedState = Object.assign({}, mockState, { endpoint: 'foo' }); - mutations[types.SET_MAIN_ENDPOINT](mockState, 'foo'); - - expect(mockState.endpoint).toEqual(expectedState.endpoint); - }); - }); - - describe('SET_IS_DELETE_DISABLED', () => { - it('should set the is delete disabled', () => { - const expectedState = Object.assign({}, mockState, { isDeleteDisabled: true }); - mutations[types.SET_IS_DELETE_DISABLED](mockState, true); - - expect(mockState.isDeleteDisabled).toEqual(expectedState.isDeleteDisabled); - }); - }); - - describe('SET_REPOS_LIST', () => { - it('should set a parsed repository list', () => { - mutations[types.SET_REPOS_LIST](mockState, reposServerResponse); - - expect(mockState.repos).toEqual(parsedReposServerResponse); - }); - }); - - describe('TOGGLE_MAIN_LOADING', () => { - it('should set a parsed repository list', () => { - mutations[types.TOGGLE_MAIN_LOADING](mockState); - - expect(mockState.isLoading).toEqual(true); - }); - }); - - describe('SET_REGISTRY_LIST', () => { - it('should set a list of registries in a specific repository', () => { - mutations[types.SET_REPOS_LIST](mockState, reposServerResponse); - mutations[types.SET_REGISTRY_LIST](mockState, { - repo: mockState.repos[0], - resp: registryServerResponse, - headers: { - 'x-per-page': 2, - 'x-page': 1, - 'x-total': 10, - }, - }); - - expect(mockState.repos[0].list).toEqual(parsedRegistryServerResponse); - expect(mockState.repos[0].pagination).toEqual({ - perPage: 2, - page: 1, - total: 10, - totalPages: NaN, - nextPage: NaN, - previousPage: NaN, - }); - }); - }); - - describe('TOGGLE_REGISTRY_LIST_LOADING', () => { - it('should toggle isLoading property for a specific repository', () => { - mutations[types.SET_REPOS_LIST](mockState, reposServerResponse); - mutations[types.SET_REGISTRY_LIST](mockState, { - repo: mockState.repos[0], - resp: registryServerResponse, - headers: { - 'x-per-page': 2, - 'x-page': 1, - 'x-total': 10, - }, - }); - - mutations[types.TOGGLE_REGISTRY_LIST_LOADING](mockState, mockState.repos[0]); - - expect(mockState.repos[0].isLoading).toEqual(true); - }); - }); -}); diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js deleted file mode 100644 index 2b0eee8b95d..00000000000 --- a/spec/javascripts/boards/board_card_spec.js +++ /dev/null @@ -1,215 +0,0 @@ -/* global List */ -/* global ListAssignee */ -/* global ListLabel */ - -import Vue from 'vue'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; - -import eventHub from '~/boards/eventhub'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import boardCard from '~/boards/components/board_card.vue'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from './mock_data'; - -describe('Board card', () => { - let vm; - let mock; - - beforeEach(done => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - - boardsStore.create(); - boardsStore.detail.issue = {}; - - const BoardCardComp = Vue.extend(boardCard); - const list = new List(listObj); - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - - setTimeout(() => { - list.issues[0].labels.push(label1); - - vm = new BoardCardComp({ - store, - propsData: { - list, - issue: list.issues[0], - issueLinkBase: '/', - disabled: false, - index: 0, - rootPath: '/', - }, - }).$mount(); - done(); - }, 0); - }); - - afterEach(() => { - mock.restore(); - }); - - it('returns false when detailIssue is empty', () => { - expect(vm.issueDetailVisible).toBe(false); - }); - - it('returns true when detailIssue is equal to card issue', () => { - boardsStore.detail.issue = vm.issue; - - expect(vm.issueDetailVisible).toBe(true); - }); - - it("returns false when multiSelect doesn't contain issue", () => { - expect(vm.multiSelectVisible).toBe(false); - }); - - it('returns true when multiSelect contains issue', () => { - boardsStore.multiSelect.list = [vm.issue]; - - expect(vm.multiSelectVisible).toBe(true); - }); - - it('adds user-can-drag class if not disabled', () => { - expect(vm.$el.classList.contains('user-can-drag')).toBe(true); - }); - - it('does not add user-can-drag class disabled', done => { - vm.disabled = true; - - setTimeout(() => { - expect(vm.$el.classList.contains('user-can-drag')).toBe(false); - done(); - }, 0); - }); - - it('does not add disabled class', () => { - expect(vm.$el.classList.contains('is-disabled')).toBe(false); - }); - - it('adds disabled class is disabled is true', done => { - vm.disabled = true; - - setTimeout(() => { - expect(vm.$el.classList.contains('is-disabled')).toBe(true); - done(); - }, 0); - }); - - describe('mouse events', () => { - const triggerEvent = (eventName, el = vm.$el) => { - const event = document.createEvent('MouseEvents'); - event.initMouseEvent( - eventName, - true, - true, - window, - 1, - 0, - 0, - 0, - 0, - false, - false, - false, - false, - 0, - null, - ); - - el.dispatchEvent(event); - }; - - it('sets showDetail to true on mousedown', () => { - triggerEvent('mousedown'); - - expect(vm.showDetail).toBe(true); - }); - - it('sets showDetail to false on mousemove', () => { - triggerEvent('mousedown'); - - expect(vm.showDetail).toBe(true); - - triggerEvent('mousemove'); - - expect(vm.showDetail).toBe(false); - }); - - it('does not set detail issue if showDetail is false', () => { - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if link is clicked', () => { - triggerEvent('mouseup', vm.$el.querySelector('a')); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if img is clicked', done => { - vm.issue.assignees = [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }), - ]; - - Vue.nextTick(() => { - triggerEvent('mouseup', vm.$el.querySelector('img')); - - expect(boardsStore.detail.issue).toEqual({}); - - done(); - }); - }); - - it('does not set detail issue if showDetail is false after mouseup', () => { - triggerEvent('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('sets detail issue to card issue on mouse up', () => { - spyOn(eventHub, '$emit'); - - triggerEvent('mousedown'); - triggerEvent('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue, undefined); - expect(boardsStore.detail.list).toEqual(vm.list); - }); - - it('adds active class if detail issue is set', done => { - vm.detailIssue.issue = vm.issue; - - Vue.nextTick() - .then(() => { - expect(vm.$el.classList.contains('is-active')).toBe(true); - }) - .then(done) - .catch(done.fail); - }); - - it('resets detail issue to empty if already set', () => { - spyOn(eventHub, '$emit'); - - boardsStore.detail.issue = vm.issue; - - triggerEvent('mousedown'); - triggerEvent('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', undefined); - }); - }); -}); diff --git a/spec/lib/gitlab/repository_size_checker_spec.rb b/spec/lib/gitlab/repository_size_checker_spec.rb new file mode 100644 index 00000000000..61f76d716e5 --- /dev/null +++ b/spec/lib/gitlab/repository_size_checker_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::RepositorySizeChecker do + let(:current_size) { 0 } + let(:limit) { 50 } + let(:enabled) { true } + + subject do + described_class.new( + current_size_proc: -> { current_size }, + limit: limit, + enabled: enabled + ) + end + + describe '#enabled?' do + context 'when enabled' do + it 'returns true' do + expect(subject.enabled?).to be_truthy + end + end + + context 'when limit is zero' do + let(:limit) { 0 } + + it 'returns false' do + expect(subject.enabled?).to be_falsey + end + end + end + + describe '#changes_will_exceed_size_limit?' do + let(:current_size) { 49 } + + it 'returns true when changes go over' do + expect(subject.changes_will_exceed_size_limit?(2)).to be_truthy + end + + it 'returns false when changes do not go over' do + expect(subject.changes_will_exceed_size_limit?(1)).to be_falsey + end + end + + describe '#above_size_limit?' do + context 'when size is above the limit' do + let(:current_size) { 100 } + + it 'returns true' do + expect(subject.above_size_limit?).to be_truthy + end + end + + it 'returns false when not over the limit' do + expect(subject.above_size_limit?).to be_falsey + end + end + + describe '#exceeded_size' do + context 'when current size is below or equal to the limit' do + let(:current_size) { 50 } + + it 'returns zero' do + expect(subject.exceeded_size).to eq(0) + end + end + + context 'when current size is over the limit' do + let(:current_size) { 51 } + + it 'returns zero' do + expect(subject.exceeded_size).to eq(1) + end + end + + context 'when change size will be over the limit' do + let(:current_size) { 50 } + + it 'returns zero' do + expect(subject.exceeded_size(1)).to eq(1) + end + end + + context 'when change size will not be over the limit' do + let(:current_size) { 49 } + + it 'returns zero' do + expect(subject.exceeded_size(1)).to eq(0) + end + end + end +end diff --git a/spec/lib/gitlab/repository_size_error_message_spec.rb b/spec/lib/gitlab/repository_size_error_message_spec.rb new file mode 100644 index 00000000000..9e4d19cc572 --- /dev/null +++ b/spec/lib/gitlab/repository_size_error_message_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::RepositorySizeErrorMessage do + let(:checker) do + Gitlab::RepositorySizeChecker.new( + current_size_proc: -> { 15.megabytes }, + limit: 10.megabytes + ) + end + + let(:message) { checker.error_message } + let(:base_message) { 'because this repository has exceeded its size limit of 10 MB by 5 MB' } + + describe 'error messages' do + describe '#commit_error' do + it 'returns the correct message' do + expect(message.commit_error).to eq("Your changes could not be committed, #{base_message}") + end + end + + describe '#merge_error' do + it 'returns the correct message' do + expect(message.merge_error).to eq("This merge request cannot be merged, #{base_message}") + end + end + + describe '#push_error' do + context 'with exceeded_limit value' do + let(:rejection_message) do + 'because this repository has exceeded its size limit of 10 MB by 15 MB' + end + + it 'returns the correct message' do + expect(message.push_error(10.megabytes)) + .to eq("Your push has been rejected, #{rejection_message}. #{message.more_info_message}") + end + end + + context 'without exceeded_limit value' do + it 'returns the correct message' do + expect(message.push_error) + .to eq("Your push has been rejected, #{base_message}. #{message.more_info_message}") + end + end + end + + describe '#new_changes_error' do + it 'returns the correct message' do + expect(message.new_changes_error).to eq("Your push to this repository would cause it to exceed the size limit of 10 MB so it has been rejected. #{message.more_info_message}") + end + end + end +end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index d8bcd8008ce..86377e054c1 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -163,19 +163,6 @@ describe Issues::CloseService do expect(issue.metrics.first_mentioned_in_commit_at).to be_nil end end - - context 'when `store_first_mentioned_in_commit_on_issue_close` feature flag is off' do - before do - stub_feature_flags(store_first_mentioned_in_commit_on_issue_close: { enabled: false, thing: issue.project }) - end - - it 'does not update the metrics' do - subject - - expect(described_class).not_to receive(:store_first_mentioned_in_commit_at) - expect(issue.metrics.first_mentioned_in_commit_at).to be_nil - end - end end end diff --git a/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb b/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb new file mode 100644 index 00000000000..c1ec515f1fe --- /dev/null +++ b/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'editing snippet checks blob is binary' do + before do + sign_in(user) + + allow_next_instance_of(Blob) do |blob| + allow(blob).to receive(:binary?).and_return(binary) + end + + subject + end + + context 'when blob is text' do + let(:binary) { false } + + it 'responds with status 200' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:edit) + end + end + + context 'when blob is binary' do + let(:binary) { true } + + it 'redirects away' do + expect(response).to redirect_to(gitlab_snippet_path(snippet)) + end + end +end + +RSpec.shared_examples 'updating snippet checks blob is binary' do + before do + sign_in(user) + + allow_next_instance_of(Blob) do |blob| + allow(blob).to receive(:binary?).and_return(binary) + end + + subject + end + + context 'when blob is text' do + let(:binary) { false } + + it 'updates successfully' do + expect(snippet.reload.title).to eq title + expect(response).to redirect_to(gitlab_snippet_path(snippet)) + end + end + + context 'when blob is binary' do + let(:binary) { true } + + it 'redirects away without updating' do + expect(response).to redirect_to(gitlab_snippet_path(snippet)) + expect(snippet.reload.title).not_to eq title + end + end +end |