diff options
author | Marcia Ramos <virtua.creative@gmail.com> | 2018-04-17 12:38:04 -0300 |
---|---|---|
committer | Marcia Ramos <virtua.creative@gmail.com> | 2018-04-17 12:38:04 -0300 |
commit | a9c32fe099db50a1ec147b84ee31dafb9321c3a4 (patch) | |
tree | 3b0be4dbd5ce89a3adeb51091808b4200688f99a /app | |
parent | d1e0e6815fd5c39683660436aff0581e3ce9f54d (diff) | |
parent | 4355a13ad4e04b1ae82b60858376c426ae041699 (diff) | |
download | gitlab-ce-a9c32fe099db50a1ec147b84ee31dafb9321c3a4.tar.gz |
Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce into docs-add-badges
Diffstat (limited to 'app')
428 files changed, 6387 insertions, 3054 deletions
diff --git a/app/assets/images/ext_snippet_icons/ext_snippet_icons.png b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png Binary files differnew file mode 100644 index 00000000000..20380adc4e5 --- /dev/null +++ b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png diff --git a/app/assets/images/ext_snippet_icons/logo.png b/app/assets/images/ext_snippet_icons/logo.png Binary files differnew file mode 100644 index 00000000000..794c9cc2dbc --- /dev/null +++ b/app/assets/images/ext_snippet_icons/logo.png diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 0e1ca7fe883..976d32abe9b 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -4,7 +4,8 @@ import $ from 'jquery'; import _ from 'underscore'; import Cookies from 'js-cookie'; import { __ } from './locale'; -import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; +import { updateTooltipTitle } from './lib/utils/common_utils'; +import { isInVueNoteablePage } from './lib/utils/dom_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -243,7 +244,7 @@ class AwardsHandler { addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length; - if (this.isInVueNoteablePage() && !isMainAwardsBlock) { + if (isInVueNoteablePage() && !isMainAwardsBlock) { const id = votesBlock.attr('id').replace('note_', ''); this.hideMenuElement($('.emoji-menu')); @@ -295,16 +296,8 @@ class AwardsHandler { } } - isVueMRDiscussions() { - return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); - } - - isInVueNoteablePage() { - return isInIssuePage() || isInEpicPage() || this.isVueMRDiscussions(); - } - getVotesBlock() { - if (this.isInVueNoteablePage()) { + if (isInVueNoteablePage()) { const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); if ($el.length) { diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue new file mode 100644 index 00000000000..6e6cb31e3ac --- /dev/null +++ b/app/assets/javascripts/badges/components/badge.vue @@ -0,0 +1,121 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import Tooltip from '~/vue_shared/directives/tooltip'; + +export default { + name: 'Badge', + components: { + Icon, + LoadingIcon, + Tooltip, + }, + directives: { + Tooltip, + }, + props: { + imageUrl: { + type: String, + required: true, + }, + linkUrl: { + type: String, + required: true, + }, + }, + data() { + return { + hasError: false, + isLoading: true, + numRetries: 0, + }; + }, + computed: { + imageUrlWithRetries() { + if (this.numRetries === 0) { + return this.imageUrl; + } + + return `${this.imageUrl}#retries=${this.numRetries}`; + }, + }, + watch: { + imageUrl() { + this.hasError = false; + this.isLoading = true; + this.numRetries = 0; + }, + }, + methods: { + onError() { + this.isLoading = false; + this.hasError = true; + }, + onLoad() { + this.isLoading = false; + }, + reloadImage() { + this.hasError = false; + this.isLoading = true; + this.numRetries += 1; + }, + }, +}; +</script> + +<template> + <div> + <a + v-show="!isLoading && !hasError" + :href="linkUrl" + target="_blank" + rel="noopener noreferrer" + > + <img + class="project-badge" + :src="imageUrlWithRetries" + @load="onLoad" + @error="onError" + aria-hidden="true" + /> + </a> + + <loading-icon + v-show="isLoading" + :inline="true" + /> + + <div + v-show="hasError" + class="btn-group" + > + <div class="btn btn-default btn-xs disabled"> + <icon + class="prepend-left-8 append-right-8" + name="doc_image" + :size="16" + aria-hidden="true" + /> + </div> + <div + class="btn btn-default btn-xs disabled" + > + <span class="prepend-left-8 append-right-8">{{ s__('Badges|No badge image') }}</span> + </div> + </div> + + <button + v-show="hasError" + class="btn btn-transparent btn-xs text-primary" + type="button" + v-tooltip + :title="s__('Badges|Reload badge image')" + @click="reloadImage" + > + <icon + name="retry" + :size="16" + /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue new file mode 100644 index 00000000000..ae942b2c1a7 --- /dev/null +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -0,0 +1,219 @@ +<script> +import _ from 'underscore'; +import { mapActions, mapState } from 'vuex'; +import createFlash from '~/flash'; +import { s__, sprintf } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import createEmptyBadge from '../empty_badge'; +import Badge from './badge.vue'; + +const badgePreviewDelayInMilliseconds = 1500; + +export default { + name: 'BadgeForm', + components: { + Badge, + LoadingButton, + LoadingIcon, + }, + props: { + isEditing: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapState([ + 'badgeInAddForm', + 'badgeInEditForm', + 'docsUrl', + 'isRendering', + 'isSaving', + 'renderedBadge', + ]), + badge() { + if (this.isEditing) { + return this.badgeInEditForm; + } + + return this.badgeInAddForm; + }, + canSubmit() { + return ( + this.badge !== null && + this.badge.imageUrl && + this.badge.imageUrl.trim() !== '' && + this.badge.linkUrl && + this.badge.linkUrl.trim() !== '' && + !this.isSaving + ); + }, + helpText() { + const placeholders = ['project_path', 'project_id', 'default_branch', 'commit_sha'] + .map(placeholder => `<code>%{${placeholder}}</code>`) + .join(', '); + return sprintf( + s__('Badges|The %{docsLinkStart}variables%{docsLinkEnd} GitLab supports: %{placeholders}'), + { + docsLinkEnd: '</a>', + docsLinkStart: `<a href="${_.escape(this.docsUrl)}">`, + placeholders, + }, + false, + ); + }, + renderedImageUrl() { + return this.renderedBadge ? this.renderedBadge.renderedImageUrl : ''; + }, + renderedLinkUrl() { + return this.renderedBadge ? this.renderedBadge.renderedLinkUrl : ''; + }, + imageUrl: { + get() { + return this.badge ? this.badge.imageUrl : ''; + }, + set(imageUrl) { + const badge = this.badge || createEmptyBadge(); + this.updateBadgeInForm({ + ...badge, + imageUrl, + }); + }, + }, + linkUrl: { + get() { + return this.badge ? this.badge.linkUrl : ''; + }, + set(linkUrl) { + const badge = this.badge || createEmptyBadge(); + this.updateBadgeInForm({ + ...badge, + linkUrl, + }); + }, + }, + submitButtonLabel() { + if (this.isEditing) { + return s__('Badges|Save changes'); + } + return s__('Badges|Add badge'); + }, + }, + methods: { + ...mapActions(['addBadge', 'renderBadge', 'saveBadge', 'stopEditing', 'updateBadgeInForm']), + debouncedPreview: _.debounce(function preview() { + this.renderBadge(); + }, badgePreviewDelayInMilliseconds), + onCancel() { + this.stopEditing(); + }, + onSubmit() { + if (!this.canSubmit) { + return Promise.resolve(); + } + + if (this.isEditing) { + return this.saveBadge() + .then(() => { + createFlash(s__('Badges|The badge was saved.'), 'notice'); + }) + .catch(error => { + createFlash( + s__('Badges|Saving the badge failed, please check the entered URLs and try again.'), + ); + throw error; + }); + } + + return this.addBadge() + .then(() => { + createFlash(s__('Badges|A new badge was added.'), 'notice'); + }) + .catch(error => { + createFlash( + s__('Badges|Adding the badge failed, please check the entered URLs and try again.'), + ); + throw error; + }); + }, + }, + badgeImageUrlPlaceholder: + 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/<badge>.svg', + badgeLinkUrlPlaceholder: 'https://example.gitlab.com/%{project_path}', +}; +</script> + +<template> + <form + class="prepend-top-default append-bottom-default" + @submit.prevent.stop="onSubmit" + > + <div class="form-group"> + <label for="badge-link-url">{{ s__('Badges|Link') }}</label> + <input + id="badge-link-url" + type="text" + class="form-control" + v-model="linkUrl" + :placeholder="$options.badgeLinkUrlPlaceholder" + @input="debouncedPreview" + /> + <span + class="help-block" + v-html="helpText" + ></span> + </div> + + <div class="form-group"> + <label for="badge-image-url">{{ s__('Badges|Badge image URL') }}</label> + <input + id="badge-image-url" + type="text" + class="form-control" + v-model="imageUrl" + :placeholder="$options.badgeImageUrlPlaceholder" + @input="debouncedPreview" + /> + <span + class="help-block" + v-html="helpText" + ></span> + </div> + + <div class="form-group"> + <label for="badge-preview">{{ s__('Badges|Badge image preview') }}</label> + <badge + id="badge-preview" + v-show="renderedBadge && !isRendering" + :image-url="renderedImageUrl" + :link-url="renderedLinkUrl" + /> + <p v-show="isRendering"> + <loading-icon + :inline="true" + /> + </p> + <p + v-show="!renderedBadge && !isRendering" + class="disabled-content" + >{{ s__('Badges|No image to preview') }}</p> + </div> + + <div class="row-content-block"> + <loading-button + type="submit" + container-class="btn btn-success" + :disabled="!canSubmit" + :loading="isSaving" + :label="submitButtonLabel" + /> + <button + class="btn btn-cancel" + type="button" + v-if="isEditing" + @click="onCancel" + >{{ __('Cancel') }}</button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue new file mode 100644 index 00000000000..ca7197e1e0f --- /dev/null +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -0,0 +1,57 @@ +<script> +import { mapState } from 'vuex'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import BadgeListRow from './badge_list_row.vue'; +import { GROUP_BADGE } from '../constants'; + +export default { + name: 'BadgeList', + components: { + BadgeListRow, + LoadingIcon, + }, + computed: { + ...mapState(['badges', 'isLoading', 'kind']), + hasNoBadges() { + return !this.isLoading && (!this.badges || !this.badges.length); + }, + isGroupBadge() { + return this.kind === GROUP_BADGE; + }, + }, +}; +</script> + +<template> + <div class="panel panel-default"> + <div class="panel-heading"> + {{ s__('Badges|Your badges') }} + <span + v-show="!isLoading" + class="badge" + >{{ badges.length }}</span> + </div> + <loading-icon + v-show="isLoading" + class="panel-body" + size="2" + /> + <div + v-if="hasNoBadges" + class="panel-body" + > + <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span> + <span v-else>{{ s__('Badges|This project has no badges') }}</span> + </div> + <div + v-else + class="panel-body" + > + <badge-list-row + v-for="badge in badges" + :key="badge.id" + :badge="badge" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue new file mode 100644 index 00000000000..af062bdf8c6 --- /dev/null +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -0,0 +1,89 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import { PROJECT_BADGE } from '../constants'; +import Badge from './badge.vue'; + +export default { + name: 'BadgeListRow', + components: { + Badge, + Icon, + LoadingIcon, + }, + props: { + badge: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['kind']), + badgeKindText() { + if (this.badge.kind === PROJECT_BADGE) { + return s__('Badges|Project Badge'); + } + + return s__('Badges|Group Badge'); + }, + canEditBadge() { + return this.badge.kind === this.kind; + }, + }, + methods: { + ...mapActions(['editBadge', 'updateBadgeInModal']), + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row-layout gl-responsive-table-row"> + <badge + class="table-section section-30" + :image-url="badge.renderedImageUrl" + :link-url="badge.renderedLinkUrl" + /> + <span class="table-section section-50 str-truncated">{{ badge.linkUrl }}</span> + <div class="table-section section-10"> + <span class="badge">{{ badgeKindText }}</span> + </div> + <div class="table-section section-10 table-button-footer"> + <div + v-if="canEditBadge" + class="table-action-buttons"> + <button + class="btn btn-default append-right-8" + type="button" + :disabled="badge.isDeleting" + @click="editBadge(badge)" + > + <icon + name="pencil" + :size="16" + :aria-label="__('Edit')" + /> + </button> + <button + class="btn btn-danger" + type="button" + data-toggle="modal" + data-target="#delete-badge-modal" + :disabled="badge.isDeleting" + @click="updateBadgeInModal(badge)" + > + <icon + name="remove" + :size="16" + :aria-label="__('Delete')" + /> + </button> + <loading-icon + v-show="badge.isDeleting" + :inline="true" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue new file mode 100644 index 00000000000..83f78394238 --- /dev/null +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -0,0 +1,70 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import Badge from './badge.vue'; +import BadgeForm from './badge_form.vue'; +import BadgeList from './badge_list.vue'; + +export default { + name: 'BadgeSettings', + components: { + Badge, + BadgeForm, + BadgeList, + GlModal, + }, + computed: { + ...mapState(['badgeInModal', 'isEditing']), + deleteModalText() { + return s__( + 'Badges|You are going to delete this badge. Deleted badges <strong>cannot</strong> be restored.', + ); + }, + }, + methods: { + ...mapActions(['deleteBadge']), + onSubmitModal() { + this.deleteBadge(this.badgeInModal) + .then(() => { + createFlash(s__('Badges|The badge was deleted.'), 'notice'); + }) + .catch(error => { + createFlash(s__('Badges|Deleting the badge failed, please try again.')); + throw error; + }); + }, + }, +}; +</script> + +<template> + <div class="badge-settings"> + <gl-modal + id="delete-badge-modal" + :header-title-text="s__('Badges|Delete badge?')" + footer-primary-button-variant="danger" + :footer-primary-button-text="s__('Badges|Delete badge')" + @submit="onSubmitModal"> + <div class="well"> + <badge + :image-url="badgeInModal ? badgeInModal.renderedImageUrl : ''" + :link-url="badgeInModal ? badgeInModal.renderedLinkUrl : ''" + /> + </div> + <p v-html="deleteModalText"></p> + </gl-modal> + + <badge-form + v-show="isEditing" + :is-editing="true" + /> + + <badge-form + v-show="!isEditing" + :is-editing="false" + /> + <badge-list v-show="!isEditing" /> + </div> +</template> diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js new file mode 100644 index 00000000000..8fbe3db5ef1 --- /dev/null +++ b/app/assets/javascripts/badges/constants.js @@ -0,0 +1,2 @@ +export const GROUP_BADGE = 'group'; +export const PROJECT_BADGE = 'project'; diff --git a/app/assets/javascripts/badges/empty_badge.js b/app/assets/javascripts/badges/empty_badge.js new file mode 100644 index 00000000000..49a9b5e1be8 --- /dev/null +++ b/app/assets/javascripts/badges/empty_badge.js @@ -0,0 +1,7 @@ +export default () => ({ + imageUrl: '', + isDeleting: false, + linkUrl: '', + renderedImageUrl: '', + renderedLinkUrl: '', +}); diff --git a/app/assets/javascripts/badges/store/actions.js b/app/assets/javascripts/badges/store/actions.js new file mode 100644 index 00000000000..5542278b3e0 --- /dev/null +++ b/app/assets/javascripts/badges/store/actions.js @@ -0,0 +1,167 @@ +import axios from '~/lib/utils/axios_utils'; +import types from './mutation_types'; + +export const transformBackendBadge = badge => ({ + id: badge.id, + imageUrl: badge.image_url, + kind: badge.kind, + linkUrl: badge.link_url, + renderedImageUrl: badge.rendered_image_url, + renderedLinkUrl: badge.rendered_link_url, + isDeleting: false, +}); + +export default { + requestNewBadge({ commit }) { + commit(types.REQUEST_NEW_BADGE); + }, + receiveNewBadge({ commit }, newBadge) { + commit(types.RECEIVE_NEW_BADGE, newBadge); + }, + receiveNewBadgeError({ commit }) { + commit(types.RECEIVE_NEW_BADGE_ERROR); + }, + addBadge({ dispatch, state }) { + const newBadge = state.badgeInAddForm; + const endpoint = state.apiEndpointUrl; + dispatch('requestNewBadge'); + return axios + .post(endpoint, { + image_url: newBadge.imageUrl, + link_url: newBadge.linkUrl, + }) + .catch(error => { + dispatch('receiveNewBadgeError'); + throw error; + }) + .then(res => { + dispatch('receiveNewBadge', transformBackendBadge(res.data)); + }); + }, + requestDeleteBadge({ commit }, badgeId) { + commit(types.REQUEST_DELETE_BADGE, badgeId); + }, + receiveDeleteBadge({ commit }, badgeId) { + commit(types.RECEIVE_DELETE_BADGE, badgeId); + }, + receiveDeleteBadgeError({ commit }, badgeId) { + commit(types.RECEIVE_DELETE_BADGE_ERROR, badgeId); + }, + deleteBadge({ dispatch, state }, badge) { + const badgeId = badge.id; + dispatch('requestDeleteBadge', badgeId); + const endpoint = `${state.apiEndpointUrl}/${badgeId}`; + return axios + .delete(endpoint) + .catch(error => { + dispatch('receiveDeleteBadgeError', badgeId); + throw error; + }) + .then(() => { + dispatch('receiveDeleteBadge', badgeId); + }); + }, + + editBadge({ commit }, badge) { + commit(types.START_EDITING, badge); + }, + + requestLoadBadges({ commit }, data) { + commit(types.REQUEST_LOAD_BADGES, data); + }, + receiveLoadBadges({ commit }, badges) { + commit(types.RECEIVE_LOAD_BADGES, badges); + }, + receiveLoadBadgesError({ commit }) { + commit(types.RECEIVE_LOAD_BADGES_ERROR); + }, + + loadBadges({ dispatch, state }, data) { + dispatch('requestLoadBadges', data); + const endpoint = state.apiEndpointUrl; + return axios + .get(endpoint) + .catch(error => { + dispatch('receiveLoadBadgesError'); + throw error; + }) + .then(res => { + dispatch('receiveLoadBadges', res.data.map(transformBackendBadge)); + }); + }, + + requestRenderedBadge({ commit }) { + commit(types.REQUEST_RENDERED_BADGE); + }, + receiveRenderedBadge({ commit }, renderedBadge) { + commit(types.RECEIVE_RENDERED_BADGE, renderedBadge); + }, + receiveRenderedBadgeError({ commit }) { + commit(types.RECEIVE_RENDERED_BADGE_ERROR); + }, + + renderBadge({ dispatch, state }) { + const badge = state.isEditing ? state.badgeInEditForm : state.badgeInAddForm; + const { linkUrl, imageUrl } = badge; + if (!linkUrl || linkUrl.trim() === '' || !imageUrl || imageUrl.trim() === '') { + return Promise.resolve(badge); + } + + dispatch('requestRenderedBadge'); + + const parameters = [ + `link_url=${encodeURIComponent(linkUrl)}`, + `image_url=${encodeURIComponent(imageUrl)}`, + ].join('&'); + const renderEndpoint = `${state.apiEndpointUrl}/render?${parameters}`; + return axios + .get(renderEndpoint) + .catch(error => { + dispatch('receiveRenderedBadgeError'); + throw error; + }) + .then(res => { + dispatch('receiveRenderedBadge', transformBackendBadge(res.data)); + }); + }, + + requestUpdatedBadge({ commit }) { + commit(types.REQUEST_UPDATED_BADGE); + }, + receiveUpdatedBadge({ commit }, updatedBadge) { + commit(types.RECEIVE_UPDATED_BADGE, updatedBadge); + }, + receiveUpdatedBadgeError({ commit }) { + commit(types.RECEIVE_UPDATED_BADGE_ERROR); + }, + + saveBadge({ dispatch, state }) { + const badge = state.badgeInEditForm; + const endpoint = `${state.apiEndpointUrl}/${badge.id}`; + dispatch('requestUpdatedBadge'); + return axios + .put(endpoint, { + image_url: badge.imageUrl, + link_url: badge.linkUrl, + }) + .catch(error => { + dispatch('receiveUpdatedBadgeError'); + throw error; + }) + .then(res => { + dispatch('receiveUpdatedBadge', transformBackendBadge(res.data)); + }); + }, + + stopEditing({ commit }) { + commit(types.STOP_EDITING); + }, + + updateBadgeInForm({ commit }, badge) { + commit(types.UPDATE_BADGE_IN_FORM, badge); + }, + + updateBadgeInModal({ commit }, badge) { + commit(types.UPDATE_BADGE_IN_MODAL, badge); + }, +}; diff --git a/app/assets/javascripts/badges/store/index.js b/app/assets/javascripts/badges/store/index.js new file mode 100644 index 00000000000..7a5df403a0e --- /dev/null +++ b/app/assets/javascripts/badges/store/index.js @@ -0,0 +1,13 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: createState(), + actions, + mutations, +}); diff --git a/app/assets/javascripts/badges/store/mutation_types.js b/app/assets/javascripts/badges/store/mutation_types.js new file mode 100644 index 00000000000..d73f91b6005 --- /dev/null +++ b/app/assets/javascripts/badges/store/mutation_types.js @@ -0,0 +1,21 @@ +export default { + RECEIVE_DELETE_BADGE: 'RECEIVE_DELETE_BADGE', + RECEIVE_DELETE_BADGE_ERROR: 'RECEIVE_DELETE_BADGE_ERROR', + RECEIVE_LOAD_BADGES: 'RECEIVE_LOAD_BADGES', + RECEIVE_LOAD_BADGES_ERROR: 'RECEIVE_LOAD_BADGES_ERROR', + RECEIVE_NEW_BADGE: 'RECEIVE_NEW_BADGE', + RECEIVE_NEW_BADGE_ERROR: 'RECEIVE_NEW_BADGE_ERROR', + RECEIVE_RENDERED_BADGE: 'RECEIVE_RENDERED_BADGE', + RECEIVE_RENDERED_BADGE_ERROR: 'RECEIVE_RENDERED_BADGE_ERROR', + RECEIVE_UPDATED_BADGE: 'RECEIVE_UPDATED_BADGE', + RECEIVE_UPDATED_BADGE_ERROR: 'RECEIVE_UPDATED_BADGE_ERROR', + REQUEST_DELETE_BADGE: 'REQUEST_DELETE_BADGE', + REQUEST_LOAD_BADGES: 'REQUEST_LOAD_BADGES', + REQUEST_NEW_BADGE: 'REQUEST_NEW_BADGE', + REQUEST_RENDERED_BADGE: 'REQUEST_RENDERED_BADGE', + REQUEST_UPDATED_BADGE: 'REQUEST_UPDATED_BADGE', + START_EDITING: 'START_EDITING', + STOP_EDITING: 'STOP_EDITING', + UPDATE_BADGE_IN_FORM: 'UPDATE_BADGE_IN_FORM', + UPDATE_BADGE_IN_MODAL: 'UPDATE_BADGE_IN_MODAL', +}; diff --git a/app/assets/javascripts/badges/store/mutations.js b/app/assets/javascripts/badges/store/mutations.js new file mode 100644 index 00000000000..bd84e68c00f --- /dev/null +++ b/app/assets/javascripts/badges/store/mutations.js @@ -0,0 +1,158 @@ +import types from './mutation_types'; +import { PROJECT_BADGE } from '../constants'; + +const reorderBadges = badges => + badges.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === PROJECT_BADGE ? 1 : -1; + } + + return a.id - b.id; + }); + +export default { + [types.RECEIVE_NEW_BADGE](state, newBadge) { + Object.assign(state, { + badgeInAddForm: null, + badges: reorderBadges(state.badges.concat(newBadge)), + isSaving: false, + renderedBadge: null, + }); + }, + [types.RECEIVE_NEW_BADGE_ERROR](state) { + Object.assign(state, { + isSaving: false, + }); + }, + [types.REQUEST_NEW_BADGE](state) { + Object.assign(state, { + isSaving: true, + }); + }, + + [types.RECEIVE_UPDATED_BADGE](state, updatedBadge) { + const badges = state.badges.map(badge => { + if (badge.id === updatedBadge.id) { + return updatedBadge; + } + return badge; + }); + Object.assign(state, { + badgeInEditForm: null, + badges, + isEditing: false, + isSaving: false, + renderedBadge: null, + }); + }, + [types.RECEIVE_UPDATED_BADGE_ERROR](state) { + Object.assign(state, { + isSaving: false, + }); + }, + [types.REQUEST_UPDATED_BADGE](state) { + Object.assign(state, { + isSaving: true, + }); + }, + + [types.RECEIVE_LOAD_BADGES](state, badges) { + Object.assign(state, { + badges: reorderBadges(badges), + isLoading: false, + }); + }, + [types.RECEIVE_LOAD_BADGES_ERROR](state) { + Object.assign(state, { + isLoading: false, + }); + }, + [types.REQUEST_LOAD_BADGES](state, data) { + Object.assign(state, { + kind: data.kind, // project or group + apiEndpointUrl: data.apiEndpointUrl, + docsUrl: data.docsUrl, + isLoading: true, + }); + }, + + [types.RECEIVE_DELETE_BADGE](state, badgeId) { + const badges = state.badges.filter(badge => badge.id !== badgeId); + Object.assign(state, { + badges, + }); + }, + [types.RECEIVE_DELETE_BADGE_ERROR](state, badgeId) { + const badges = state.badges.map(badge => { + if (badge.id === badgeId) { + return { + ...badge, + isDeleting: false, + }; + } + + return badge; + }); + Object.assign(state, { + badges, + }); + }, + [types.REQUEST_DELETE_BADGE](state, badgeId) { + const badges = state.badges.map(badge => { + if (badge.id === badgeId) { + return { + ...badge, + isDeleting: true, + }; + } + + return badge; + }); + Object.assign(state, { + badges, + }); + }, + + [types.RECEIVE_RENDERED_BADGE](state, renderedBadge) { + Object.assign(state, { isRendering: false, renderedBadge }); + }, + [types.RECEIVE_RENDERED_BADGE_ERROR](state) { + Object.assign(state, { isRendering: false }); + }, + [types.REQUEST_RENDERED_BADGE](state) { + Object.assign(state, { isRendering: true }); + }, + + [types.START_EDITING](state, badge) { + Object.assign(state, { + badgeInEditForm: { ...badge }, + isEditing: true, + renderedBadge: { ...badge }, + }); + }, + [types.STOP_EDITING](state) { + Object.assign(state, { + badgeInEditForm: null, + isEditing: false, + renderedBadge: null, + }); + }, + + [types.UPDATE_BADGE_IN_FORM](state, badge) { + if (state.isEditing) { + Object.assign(state, { + badgeInEditForm: badge, + }); + } else { + Object.assign(state, { + badgeInAddForm: badge, + }); + } + }, + + [types.UPDATE_BADGE_IN_MODAL](state, badge) { + Object.assign(state, { + badgeInModal: badge, + }); + }, +}; diff --git a/app/assets/javascripts/badges/store/state.js b/app/assets/javascripts/badges/store/state.js new file mode 100644 index 00000000000..43413aeb5bb --- /dev/null +++ b/app/assets/javascripts/badges/store/state.js @@ -0,0 +1,13 @@ +export default () => ({ + apiEndpointUrl: null, + badgeInAddForm: null, + badgeInEditForm: null, + badgeInModal: null, + badges: [], + docsUrl: null, + renderedBadge: null, + isEditing: false, + isLoading: false, + isRendering: false, + isSaving: false, +}); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 030ca1907e5..ff1cbcad145 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -94,7 +94,7 @@ export default class FileTemplateMediator { const hash = urlPieces[1]; if (hash === 'preview') { this.hideTemplateSelectorMenu(); - } else if (hash === 'editor') { + } else if (hash === 'editor' && !this.typeSelector.isHidden()) { this.showTemplateSelectorMenu(); } }); diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index e52cf249f3a..02228434a29 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -32,6 +32,10 @@ export default class FileTemplateSelector { } } + isHidden() { + return this.$wrapper.hasClass('hidden'); + } + getToggleText() { return this.$dropdownToggleText.text(); } diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 3cffd91716a..bea818010a4 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -5,7 +5,7 @@ import Sortable from 'vendor/Sortable'; import Vue from 'vue'; import AccessorUtilities from '../../lib/utils/accessor'; import boardList from './board_list.vue'; -import boardBlankState from './board_blank_state'; +import BoardBlankState from './board_blank_state.vue'; import './board_delete'; const Store = gl.issueBoards.BoardsStore; @@ -18,7 +18,7 @@ gl.issueBoards.Board = Vue.extend({ components: { boardList, 'board-delete': gl.issueBoards.BoardDelete, - boardBlankState, + BoardBlankState, }, props: { list: Object, diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.vue index 72db626d3c7..2049eeb9c30 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.js +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,42 +1,11 @@ +<script> /* global ListLabel */ - import _ from 'underscore'; import Cookies from 'js-cookie'; const Store = gl.issueBoards.BoardsStore; export default { - template: ` - <div class="board-blank-state"> - <p> - Add the following default lists to your Issue Board with one click: - </p> - <ul class="board-blank-state-list"> - <li v-for="label in predefinedLabels"> - <span - class="label-color" - :style="{ backgroundColor: label.color }"> - </span> - {{ label.title }} - </li> - </ul> - <p> - Starting out with the default set of lists will get you right on the way to making the most of your board. - </p> - <button - class="btn btn-create btn-inverted btn-block" - type="button" - @click.stop="addDefaultLists"> - Add default lists - </button> - <button - class="btn btn-default btn-block" - type="button" - @click.stop="clearBlankState"> - Nevermind, I'll use my own - </button> - </div> - `, data() { return { predefinedLabels: [ @@ -89,3 +58,41 @@ export default { clearBlankState: Store.removeBlankState.bind(Store), }, }; + +</script> + +<template> + <div class="board-blank-state"> + <p> + Add the following default lists to your Issue Board with one click: + </p> + <ul class="board-blank-state-list"> + <li + v-for="(label, index) in predefinedLabels" + :key="index" + > + <span + class="label-color" + :style="{ backgroundColor: label.color }"> + </span> + {{ label.title }} + </li> + </ul> + <p> + Starting out with the default set of lists will get you + right on the way to making the most of your board. + </p> + <button + class="btn btn-create btn-inverted btn-block" + type="button" + @click.stop="addDefaultLists"> + Add default lists + </button> + <button + class="btn btn-default btn-block" + type="button" + @click.stop="clearBlankState"> + Nevermind, I'll use my own + </button> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index a44969272a1..c4ee4f6c855 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -60,10 +60,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({ this.issue = this.detail.issue; this.list = this.detail.list; - - this.$nextTick(() => { - this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate; - }); }, deep: true }, @@ -91,7 +87,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ saveAssignees () { this.loadingAssignees = true; - gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint) + gl.issueBoards.BoardsStore.detail.issue.update() .then(() => { this.loadingAssignees = false; }) diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index 8aee5b23c76..84fe9b1288a 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -68,15 +68,6 @@ gl.issueBoards.IssueCardInner = Vue.extend({ return this.issue.assignees.length > this.numberOverLimit; }, - cardUrl() { - let baseUrl = this.issueLinkBase; - - if (this.groupId && this.issue.project) { - baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path); - } - - return `${baseUrl}/${this.issue.iid}`; - }, issueId() { if (this.issue.iid) { return `#${this.issue.iid}`; @@ -153,13 +144,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({ /> <a class="js-no-trigger" - :href="cardUrl" + :href="issue.path" :title="issue.title">{{ issue.title }}</a> <span class="card-number" v-if="issueId" > - <template v-if="groupId && issue.project">{{issue.project.path}}</template>{{ issueId }} + {{ issue.referencePath }} </span> </h4> <div class="card-assignee"> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js index e571b11a83d..9e37f95cdd6 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.js +++ b/app/assets/javascripts/boards/components/modal/empty_state.js @@ -1,9 +1,9 @@ import Vue from 'vue'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; +import modalMixin from '../../mixins/modal_mixins'; gl.issueBoards.ModalEmptyState = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], data() { return ModalStore.store; }, diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index 03cd7ef65cb..9735e0ddacc 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -3,11 +3,11 @@ import Flash from '../../../flash'; import { __ } from '../../../locale'; import './lists_dropdown'; import { pluralize } from '../../../lib/utils/text_utility'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; +import modalMixin from '../../mixins/modal_mixins'; gl.issueBoards.ModalFooter = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], data() { return { modal: ModalStore.store, diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js index 31f59d295bf..67c29ebca72 100644 --- a/app/assets/javascripts/boards/components/modal/header.js +++ b/app/assets/javascripts/boards/components/modal/header.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import modalFilters from './filters'; import './tabs'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; +import modalMixin from '../../mixins/modal_mixins'; gl.issueBoards.ModalHeader = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], props: { projectId: { type: Number, diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index d825ff38587..3083b3e4405 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -7,8 +7,7 @@ import './header'; import './list'; import './footer'; import './empty_state'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; gl.issueBoards.IssuesModal = Vue.extend({ props: { diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js index 7c62134b3a3..6b04a6c7a6c 100644 --- a/app/assets/javascripts/boards/components/modal/list.js +++ b/app/assets/javascripts/boards/components/modal/list.js @@ -2,8 +2,7 @@ import Vue from 'vue'; import bp from '../../../breakpoints'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; gl.issueBoards.ModalList = Vue.extend({ props: { diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js index 4684ea76647..e644de2d4fc 100644 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js @@ -1,6 +1,5 @@ import Vue from 'vue'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ data() { diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js index 3e5d08e3d75..b6465a88e5e 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.js +++ b/app/assets/javascripts/boards/components/modal/tabs.js @@ -1,9 +1,9 @@ import Vue from 'vue'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; +import modalMixin from '../../mixins/modal_mixins'; gl.issueBoards.ModalTabs = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], data() { return ModalStore.store; }, diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js index 09c683ff621..0a0820ec5fd 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js @@ -17,14 +17,10 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ type: Object, required: true, }, - issueUpdate: { - type: String, - required: true, - }, }, computed: { updateUrl() { - return this.issueUpdate.replace(':project_path', this.issue.project.path); + return this.issue.path; }, }, methods: { diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index fb40b9f5565..70367c4f711 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -6,6 +6,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { constructor(store, updateUrl = false, cantEdit = []) { super({ page: 'boards', + isGroupDecendent: true, stateFiltersSelector: '.issues-state-filters', }); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 8b1c14c04ff..a6f8681cfac 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -17,9 +17,9 @@ import './models/milestone'; import './models/project'; import './models/assignee'; import './stores/boards_store'; -import './stores/modal_store'; +import ModalStore from './stores/modal_store'; import BoardService from './services/board_service'; -import './mixins/modal_mixins'; +import modalMixin from './mixins/modal_mixins'; import './mixins/sortable_default_options'; import './filters/due_date_filters'; import './components/board'; @@ -31,7 +31,6 @@ import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/fi export default () => { const $boardApp = document.getElementById('board-app'); const Store = gl.issueBoards.BoardsStore; - const ModalStore = gl.issueBoards.ModalStore; window.gl = window.gl || {}; @@ -176,7 +175,7 @@ export default () => { gl.IssueBoardsModalAddBtn = new Vue({ el: document.getElementById('js-add-issues-btn'), - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], data() { return { modal: ModalStore.store, diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js index 2b0a1aaa89f..6c97e1629bf 100644 --- a/app/assets/javascripts/boards/mixins/modal_mixins.js +++ b/app/assets/javascripts/boards/mixins/modal_mixins.js @@ -1,6 +1,6 @@ -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../stores/modal_store'; -gl.issueBoards.ModalMixins = { +export default { methods: { toggleModal(toggle) { ModalStore.store.showAddIssuesModal = toggle; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 4c5079efc8b..b381d48d625 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -23,6 +23,8 @@ class ListIssue { }; this.isLoading = {}; this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; + this.referencePath = obj.reference_path; + this.path = obj.real_path; this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; this.milestone_id = obj.milestone_id; this.project_id = obj.project_id; @@ -98,7 +100,7 @@ class ListIssue { this.isLoading[key] = value; } - update (url) { + update () { const data = { issue: { milestone_id: this.milestone ? this.milestone.id : null, @@ -113,7 +115,7 @@ class ListIssue { } const projectPath = this.project ? this.project.path : ''; - return Vue.http.patch(url.replace(':project_path', projectPath), data); + return Vue.http.patch(`${this.path}.json`, data); } } diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index d78d4701974..7c90597f77c 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -19,7 +19,7 @@ export default class BoardService { } static generateIssuePath(boardId, id) { - return `${gon.relative_url_root}/-/boards/${boardId ? `/${boardId}` : ''}/issues${id ? `/${id}` : ''}`; + return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${id ? `/${id}` : ''}`; } all() { diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js index 4fdc925c825..a4220cd840d 100644 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ b/app/assets/javascripts/boards/stores/modal_store.js @@ -1,6 +1,3 @@ -window.gl = window.gl || {}; -window.gl.issueBoards = window.gl.issueBoards || {}; - class ModalStore { constructor() { this.store = { @@ -95,4 +92,4 @@ class ModalStore { } } -gl.issueBoards.ModalStore = new ModalStore(); +export default new ModalStore(); diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js index 839e369eaf6..f34496f84c6 100644 --- a/app/assets/javascripts/branches/branches_delete_modal.js +++ b/app/assets/javascripts/branches/branches_delete_modal.js @@ -16,6 +16,7 @@ class DeleteModal { bindEvents() { this.$toggleBtns.on('click', this.setModalData.bind(this)); this.$confirmInput.on('input', this.setDeleteDisabled.bind(this)); + this.$deleteBtn.on('click', this.setDisableDeleteButton.bind(this)); } setModalData(e) { @@ -30,6 +31,16 @@ class DeleteModal { this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName); } + setDisableDeleteButton(e) { + if (this.$deleteBtn.is('[disabled]')) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + + return true; + } + updateModal() { this.$branchName.text(this.branchName); this.$confirmInput.val(''); diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index f8dcdf3f60a..9c12b89240c 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -1,96 +1,102 @@ <script> - import _ from 'underscore'; - import { s__, sprintf } from '../../locale'; - import applicationRow from './application_row.vue'; - import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; - import { - APPLICATION_INSTALLED, - INGRESS, - } from '../constants'; +import _ from 'underscore'; +import { s__, sprintf } from '../../locale'; +import applicationRow from './application_row.vue'; +import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import { APPLICATION_INSTALLED, INGRESS } from '../constants'; - export default { - components: { - applicationRow, - clipboardButton, +export default { + components: { + applicationRow, + clipboardButton, + }, + props: { + applications: { + type: Object, + required: false, + default: () => ({}), }, - props: { - applications: { - type: Object, - required: false, - default: () => ({}), - }, - helpPath: { - type: String, - required: false, - default: '', - }, - ingressHelpPath: { - type: String, - required: false, - default: '', - }, - ingressDnsHelpPath: { - type: String, - required: false, - default: '', - }, - managePrometheusPath: { - type: String, - required: false, - default: '', - }, + helpPath: { + type: String, + required: false, + default: '', }, - computed: { - generalApplicationDescription() { - return sprintf( - _.escape(s__( + ingressHelpPath: { + type: String, + required: false, + default: '', + }, + ingressDnsHelpPath: { + type: String, + required: false, + default: '', + }, + managePrometheusPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + generalApplicationDescription() { + return sprintf( + _.escape( + s__( `ClusterIntegration|Install applications on your Kubernetes cluster. Read more about %{helpLink}`, - )), { - helpLink: `<a href="${this.helpPath}"> + ), + ), + { + helpLink: `<a href="${this.helpPath}"> ${_.escape(s__('ClusterIntegration|installing applications'))} </a>`, - }, - false, - ); - }, - ingressId() { - return INGRESS; - }, - ingressInstalled() { - return this.applications.ingress.status === APPLICATION_INSTALLED; - }, - ingressExternalIp() { - return this.applications.ingress.externalIp; - }, - ingressDescription() { - const extraCostParagraph = sprintf( - _.escape(s__( + }, + false, + ); + }, + ingressId() { + return INGRESS; + }, + ingressInstalled() { + return this.applications.ingress.status === APPLICATION_INSTALLED; + }, + ingressExternalIp() { + return this.applications.ingress.externalIp; + }, + ingressDescription() { + const extraCostParagraph = sprintf( + _.escape( + s__( `ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which may incur additional costs depending on - the hosting provider your Kubernetes cluster is installed on. If you are using GKE, - you can %{pricingLink}.`, - )), { - boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`, - pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> + the hosting provider your Kubernetes cluster is installed on. If you are using + Google Kubernetes Engine, you can %{pricingLink}.`, + ), + ), + { + boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`, + pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> ${_.escape(s__('ClusterIntegration|check the pricing here'))}</a>`, - }, - false, - ); + }, + false, + ); - const externalIpParagraph = sprintf( - _.escape(s__( + const externalIpParagraph = sprintf( + _.escape( + s__( `ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}`, - )), { - ingressHelpLink: `<a href="${this.ingressHelpPath}"> + ), + ), + { + ingressHelpLink: `<a href="${this.ingressHelpPath}"> ${_.escape(s__('ClusterIntegration|More information'))} </a>`, - }, - false, - ); + }, + false, + ); - return ` + return ` <p> ${extraCostParagraph} </p> @@ -98,22 +104,25 @@ ${externalIpParagraph} </p> `; - }, - prometheusDescription() { - return sprintf( - _.escape(s__( + }, + prometheusDescription() { + return sprintf( + _.escape( + s__( `ClusterIntegration|Prometheus is an open-source monitoring system with %{gitlabIntegrationLink} to monitor deployed applications.`, - )), { - gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" + ), + ), + { + gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" target="_blank" rel="noopener noreferrer"> ${_.escape(s__('ClusterIntegration|GitLab Integration'))}</a>`, - }, - false, - ); - }, + }, + false, + ); }, - }; + }, +}; </script> <template> @@ -205,7 +214,7 @@ > {{ s__(`ClusterIntegration|The IP address is in the process of being assigned. Please check your Kubernetes - cluster or Quotas on GKE if it takes a long time.`) }} + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} <a :href="ingressHelpPath" diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 466a5b5d635..24d63b99a29 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -55,22 +55,20 @@ }, methods: { successCallback(resp) { - return resp.json().then((response) => { - // depending of the endpoint the response can either bring a `pipelines` key or not. - const pipelines = response.pipelines || response; - this.setCommonData(pipelines); + // depending of the endpoint the response can either bring a `pipelines` key or not. + const pipelines = resp.data.pipelines || resp.data; + this.setCommonData(pipelines); - const updatePipelinesEvent = new CustomEvent('update-pipelines-count', { - detail: { - pipelines: response, - }, - }); - - // notifiy to update the count in tabs - if (this.$el.parentElement) { - this.$el.parentElement.dispatchEvent(updatePipelinesEvent); - } + const updatePipelinesEvent = new CustomEvent('update-pipelines-count', { + detail: { + pipelines: resp.data, + }, }); + + // notifiy to update the count in tabs + if (this.$el.parentElement) { + this.$el.parentElement.dispatchEvent(updatePipelinesEvent); + } }, }, }; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js index c50ac667c20..2d5bae9a9c4 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight.js @@ -1,19 +1,19 @@ import $ from 'jquery'; -import _ from 'underscore'; import { getSelector, - togglePopover, inserted, - mouseenter, - mouseleave, } from './feature_highlight_helper'; +import { + togglePopover, + mouseenter, + debouncedMouseleave, +} from '../shared/popover'; export function setupFeatureHighlightPopover(id, debounceTimeout = 300) { const $selector = $(getSelector(id)); const $parent = $selector.parent(); const $popoverContent = $parent.siblings('.feature-highlight-popover-content'); const hideOnScroll = togglePopover.bind($selector, false); - const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout); $selector // Setup popover @@ -29,13 +29,10 @@ export function setupFeatureHighlightPopover(id, debounceTimeout = 300) { `, }) .on('mouseenter', mouseenter) - .on('mouseleave', debouncedMouseleave) + .on('mouseleave', debouncedMouseleave(debounceTimeout)) .on('inserted.bs.popover', inserted) .on('show.bs.popover', () => { - window.addEventListener('scroll', hideOnScroll); - }) - .on('hide.bs.popover', () => { - window.removeEventListener('scroll', hideOnScroll); + window.addEventListener('scroll', hideOnScroll, { once: true }); }) // Display feature highlight .removeAttr('disabled'); diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js index f480e72961c..d5b97ebb264 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js @@ -3,20 +3,10 @@ import axios from '../lib/utils/axios_utils'; import { __ } from '../locale'; import Flash from '../flash'; import LazyLoader from '../lazy_loader'; +import { togglePopover } from '../shared/popover'; export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`; -export function togglePopover(show) { - const isAlreadyShown = this.hasClass('js-popover-show'); - if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) { - return false; - } - this.popover(show ? 'show' : 'hide'); - this.toggleClass('disable-animation js-popover-show', show); - - return true; -} - export function dismiss(highlightId) { axios.post(this.attr('data-dismiss-endpoint'), { feature_name: highlightId, @@ -27,23 +17,6 @@ export function dismiss(highlightId) { this.hide(); } -export function mouseleave() { - if (!$('.popover:hover').length > 0) { - const $featureHighlight = $(this); - togglePopover.call($featureHighlight, false); - } -} - -export function mouseenter() { - const $featureHighlight = $(this); - - const showedPopover = togglePopover.call($featureHighlight, true); - if (showedPopover) { - $('.popover') - .on('mouseleave', mouseleave.bind($featureHighlight)); - } -} - export function inserted() { const popoverId = this.getAttribute('aria-describedby'); const highlightId = this.dataset.highlight; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index e6390f0855b..d7e1de18d09 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -26,8 +26,8 @@ export default class FilteredSearchDropdownManager { this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; this.groupsOnly = isGroup; - this.groupAncestor = isGroupAncestor; - this.isGroupDecendent = isGroupDecendent; + this.includeAncestorGroups = isGroupAncestor; + this.includeDescendantGroups = isGroupDecendent; this.setupMapping(); @@ -108,7 +108,19 @@ export default class FilteredSearchDropdownManager { } getLabelsEndpoint() { - const endpoint = `${this.baseEndpoint}/labels.json`; + let endpoint = `${this.baseEndpoint}/labels.json?`; + + if (this.groupsOnly) { + endpoint = `${endpoint}only_group_labels=true&`; + } + + if (this.includeAncestorGroups) { + endpoint = `${endpoint}include_ancestor_groups=true&`; + } + + if (this.includeDescendantGroups) { + endpoint = `${endpoint}include_descendant_groups=true`; + } return endpoint; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 71b7e80335b..cf5ba1e1771 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -21,7 +21,7 @@ export default class FilteredSearchManager { constructor({ page, isGroup = false, - isGroupAncestor = false, + isGroupAncestor = true, isGroupDecendent = false, filteredSearchTokenKeys = FilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', @@ -86,6 +86,7 @@ export default class FilteredSearchManager { page: this.page, isGroup: this.isGroup, isGroupAncestor: this.isGroupAncestor, + isGroupDecendent: this.isGroupDecendent, filteredSearchTokenKeys: this.filteredSearchTokenKeys, }); diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index d22869466c9..1c237c0ec97 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -3,7 +3,6 @@ import { mapState, mapGetters } from 'vuex'; import ideSidebar from './ide_side_bar.vue'; import ideContextbar from './ide_context_bar.vue'; import repoTabs from './repo_tabs.vue'; -import repoFileButtons from './repo_file_buttons.vue'; import ideStatusBar from './ide_status_bar.vue'; import repoEditor from './repo_editor.vue'; @@ -12,7 +11,6 @@ export default { ideSidebar, ideContextbar, repoTabs, - repoFileButtons, ideStatusBar, repoEditor, }, @@ -70,9 +68,6 @@ export default { class="multi-file-edit-pane-content" :file="activeFile" /> - <repo-file-buttons - :file="activeFile" - /> <ide-status-bar :file="activeFile" /> diff --git a/app/assets/javascripts/ide/components/ide_file_buttons.vue b/app/assets/javascripts/ide/components/ide_file_buttons.vue new file mode 100644 index 00000000000..a6c6f46a144 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_file_buttons.vue @@ -0,0 +1,84 @@ +<script> +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + showButtons() { + return ( + this.file.rawPath || this.file.blamePath || this.file.commitsPath || this.file.permalink + ); + }, + rawDownloadButtonLabel() { + return this.file.binary ? __('Download') : __('Raw'); + }, + }, +}; +</script> + +<template> + <div + v-if="showButtons" + class="pull-right ide-btn-group" + > + <a + v-tooltip + v-if="!file.binary" + :href="file.blamePath" + :title="__('Blame')" + class="btn btn-xs btn-transparent blame" + > + <icon + name="blame" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.commitsPath" + :title="__('History')" + class="btn btn-xs btn-transparent history" + > + <icon + name="history" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.permalink" + :title="__('Permalink')" + class="btn btn-xs btn-transparent permalink" + > + <icon + name="link" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.rawPath" + target="_blank" + class="btn btn-xs btn-transparent prepend-left-10 raw" + rel="noopener noreferrer" + :title="rawDownloadButtonLabel"> + <icon + name="download" + :size="16" + /> + </a> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 9c386896448..152a5f632ad 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,25 +1,23 @@ <script> - import icon from '~/vue_shared/components/icon.vue'; - import tooltip from '~/vue_shared/directives/tooltip'; - import timeAgoMixin from '~/vue_shared/mixins/timeago'; +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import timeAgoMixin from '~/vue_shared/mixins/timeago'; - export default { - components: { - icon, +export default { + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [timeAgoMixin], + props: { + file: { + type: Object, + required: true, }, - directives: { - tooltip, - }, - mixins: [ - timeAgoMixin, - ], - props: { - file: { - type: Object, - required: true, - }, - }, - }; + }, +}; </script> <template> @@ -50,7 +48,9 @@ <div class="text-right"> {{ file.eol }} </div> - <div class="text-right"> + <div + class="text-right" + v-if="!file.binary"> {{ file.editorRow }}:{{ file.editorColumn }} </div> <div class="text-right"> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index b1a16350c19..711bafa17a9 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -2,10 +2,16 @@ /* global monaco */ import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '~/flash'; +import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; +import IdeFileButtons from './ide_file_buttons.vue'; export default { + components: { + ContentViewer, + IdeFileButtons, + }, props: { file: { type: Object, @@ -13,10 +19,20 @@ export default { }, }, computed: { - ...mapState(['leftPanelCollapsed', 'rightPanelCollapsed', 'viewer', 'delayViewerUpdated']), + ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), ...mapGetters(['currentMergeRequest']), shouldHideEditor() { - return this.file && this.file.binary && !this.file.raw; + return this.file && this.file.binary && !this.file.content; + }, + editTabCSS() { + return { + active: this.file.viewMode === 'edit', + }; + }, + previewTabCSS() { + return { + active: this.file.viewMode === 'preview', + }; }, }, watch: { @@ -26,15 +42,17 @@ export default { this.initMonaco(); } }, - leftPanelCollapsed() { - this.editor.updateDimensions(); - }, rightPanelCollapsed() { this.editor.updateDimensions(); }, viewer() { this.createEditorInstance(); }, + panelResizing() { + if (!this.panelResizing) { + this.editor.updateDimensions(); + } + }, }, beforeDestroy() { this.editor.dispose(); @@ -56,6 +74,7 @@ export default { 'changeFileContent', 'setFileLanguage', 'setEditorPosition', + 'setFileViewMode', 'setFileEOL', 'updateViewer', 'updateDelayViewerUpdated', @@ -152,16 +171,49 @@ export default { id="ide" class="blob-viewer-container blob-editor-container" > - <div - v-if="shouldHideEditor" - v-html="file.html" - > + <div class="ide-mode-tabs clearfix"> + <ul + class="nav-links pull-left" + v-if="!shouldHideEditor"> + <li :class="editTabCSS"> + <a + href="javascript:void(0);" + role="button" + @click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> + <template v-if="viewer === 'editor'"> + {{ __('Edit') }} + </template> + <template v-else> + {{ __('Review') }} + </template> + </a> + </li> + <li + v-if="file.previewMode" + :class="previewTabCSS"> + <a + href="javascript:void(0);" + role="button" + @click.prevent="setFileViewMode({ file, viewMode:'preview' })"> + {{ file.previewMode.previewTitle }} + </a> + </li> + </ul> + <ide-file-buttons + :file="file" + /> </div> <div - v-show="!shouldHideEditor" + v-show="!shouldHideEditor && file.viewMode === 'edit'" ref="editor" class="multi-file-editor-holder" > </div> + <content-viewer + v-if="shouldHideEditor || file.viewMode === 'preview'" + :content="file.content || file.raw" + :path="file.rawPath || file.path" + :file-size="file.size" + :project-path="file.projectId"/> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue deleted file mode 100644 index 4ea8cf7504b..00000000000 --- a/app/assets/javascripts/ide/components/repo_file_buttons.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -export default { - props: { - file: { - type: Object, - required: true, - }, - }, - computed: { - showButtons() { - return this.file.rawPath || - this.file.blamePath || - this.file.commitsPath || - this.file.permalink; - }, - rawDownloadButtonLabel() { - return this.file.binary ? 'Download' : 'Raw'; - }, - }, -}; -</script> - -<template> - <div - v-if="showButtons" - class="multi-file-editor-btn-group" - > - <a - :href="file.rawPath" - target="_blank" - class="btn btn-default btn-sm raw" - rel="noopener noreferrer"> - {{ rawDownloadButtonLabel }} - </a> - - <div - class="btn-group" - role="group" - aria-label="File actions" - > - <a - :href="file.blamePath" - class="btn btn-default btn-sm blame" - > - Blame - </a> - <a - :href="file.commitsPath" - class="btn btn-default btn-sm history" - > - History - </a> - <a - :href="file.permalink" - class="btn btn-default btn-sm permalink" - > - Permalink - </a> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index faa690ecba0..5ea2a2f6825 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -1,67 +1,64 @@ <script> - import { mapActions, mapState } from 'vuex'; - import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { mapActions, mapState } from 'vuex'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; - export default { - components: { - PanelResizer, +export default { + components: { + PanelResizer, + }, + props: { + collapsible: { + type: Boolean, + required: true, }, - props: { - collapsible: { - type: Boolean, - required: true, - }, - initialWidth: { - type: Number, - required: true, - }, - minSize: { - type: Number, - required: false, - default: 200, - }, - side: { - type: String, - required: true, - }, + initialWidth: { + type: Number, + required: true, }, - data() { - return { - width: this.initialWidth, - }; + minSize: { + type: Number, + required: false, + default: 340, }, - computed: { - ...mapState({ - collapsed(state) { - return state[`${this.side}PanelCollapsed`]; - }, - }), - panelStyle() { - if (!this.collapsed) { - return { - width: `${this.width}px`, - }; - } - - return {}; - }, + side: { + type: String, + required: true, }, - methods: { - ...mapActions([ - 'setPanelCollapsedStatus', - 'setResizingStatus', - ]), - toggleFullbarCollapsed() { - if (this.collapsed && this.collapsible) { - this.setPanelCollapsedStatus({ - side: this.side, - collapsed: !this.collapsed, - }); - } + }, + data() { + return { + width: this.initialWidth, + }; + }, + computed: { + ...mapState({ + collapsed(state) { + return state[`${this.side}PanelCollapsed`]; }, + }), + panelStyle() { + if (!this.collapsed) { + return { + width: `${this.width}px`, + }; + } + + return {}; + }, + }, + methods: { + ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']), + toggleFullbarCollapsed() { + if (this.collapsed && this.collapsible) { + this.setPanelCollapsedStatus({ + side: this.side, + collapsed: !this.collapsed, + }); + } }, - maxSize: (window.innerWidth / 2), - }; + }, + maxSize: window.innerWidth / 2, +}; </script> <template> diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 20983666b4a..4a0a303d5a6 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -36,11 +36,11 @@ const router = new VueRouter({ base: `${gon.relative_url_root}/-/ide/`, routes: [ { - path: '/project/:namespace/:project', + path: '/project/:namespace/:project+', component: EmptyRouterComponent, children: [ { - path: ':targetmode/:branch/*', + path: ':targetmode(edit|tree|blob)/:branch/*', component: EmptyRouterComponent, }, { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 6b4ba30e086..001737d6ee8 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -69,6 +69,7 @@ export default class Editor { occurrencesHighlight: false, renderLineHighlight: 'none', hideCursorInOverviewRuler: true, + renderSideBySide: Editor.renderSideBySide(domElement), })), ); @@ -81,7 +82,7 @@ export default class Editor { } attachModel(model) { - if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') { + if (this.isDiffEditorType) { this.instance.setModel({ original: model.getOriginalModel(), modified: model.getModel(), @@ -153,6 +154,7 @@ export default class Editor { updateDimensions() { this.instance.layout(); + this.updateDiffView(); } setPosition({ lineNumber, column }) { @@ -171,4 +173,20 @@ export default class Editor { this.disposable.add(this.instance.onDidChangeCursorPosition(e => cb(this.instance, e))); } + + updateDiffView() { + if (!this.isDiffEditorType) return; + + this.instance.updateOptions({ + renderSideBySide: Editor.renderSideBySide(this.instance.getDomNode()), + }); + } + + get isDiffEditorType() { + return this.instance.getEditorType() === 'vs.editor.IDiffEditor'; + } + + static renderSideBySide(domElement) { + return domElement.offsetWidth >= 700; + } } diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index a213862f9b3..9f895d49f2e 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -6,7 +6,7 @@ export const defaultEditorOptions = { minimap: { enabled: false, }, - wordWrap: 'bounded', + wordWrap: 'on', }; export default [ diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 6b034ea1e82..1a17320a1ea 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -149,6 +149,10 @@ export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn } }; +export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { + commit(types.SET_FILE_VIEWMODE, { file, viewMode }); +}; + export const discardFileChanges = ({ state, commit }, path) => { const file = state.entries[path]; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index f536ce6344b..367c45f7e2d 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -37,9 +37,9 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => { const commitMsg = sprintf( __('Your changes have been committed. Commit %{commitId} %{commitStats}'), { - commitId: `<a href="${currentProject.web_url}/commit/${ + commitId: `<a href="${currentProject.web_url}/commit/${data.short_id}" class="commit-sha">${ data.short_id - }" class="commit-sha">${data.short_id}</a>`, + }</a>`, commitStats, }, false, @@ -54,9 +54,7 @@ export const checkCommitStatus = ({ rootState }) => .then(({ data }) => { const { id } = data.commit; const selectedBranch = - rootState.projects[rootState.currentProjectId].branches[ - rootState.currentBranchId - ]; + rootState.projects[rootState.currentProjectId].branches[rootState.currentBranchId]; if (selectedBranch.workingReference !== id) { return true; @@ -135,32 +133,15 @@ export const updateFilesAfterCommit = ( if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { router.push( - `/project/${rootState.currentProjectId}/blob/${branch}/${ - rootGetters.activeFile.path - }`, + `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`, ); } - - dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH); }; -export const commitChanges = ({ - commit, - state, - getters, - dispatch, - rootState, -}) => { +export const commitChanges = ({ commit, state, getters, dispatch, rootState }) => { const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; - const payload = createCommitPayload( - getters.branchName, - newBranch, - state, - rootState, - ); - const getCommitStatus = newBranch - ? Promise.resolve(false) - : dispatch('checkCommitStatus'); + const payload = createCommitPayload(getters.branchName, newBranch, state, rootState); + const getCommitStatus = newBranch ? Promise.resolve(false) : dispatch('checkCommitStatus'); commit(types.UPDATE_LOADING, true); @@ -182,28 +163,29 @@ export const commitChanges = ({ if (!data.short_id) { flash(data.message, 'alert', document, null, false, true); - return; + return null; } dispatch('setLastCommitMessage', data); dispatch('updateCommitMessage', ''); - - if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) { - dispatch( - 'redirectToUrl', - createNewMergeRequestUrl( - rootState.projects[rootState.currentProjectId].web_url, - getters.branchName, - rootState.currentBranchId, - ), - { root: true }, - ); - } else { - dispatch('updateFilesAfterCommit', { - data, - branch: getters.branchName, - }); - } + return dispatch('updateFilesAfterCommit', { + data, + branch: getters.branchName, + }) + .then(() => { + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) { + dispatch( + 'redirectToUrl', + createNewMergeRequestUrl( + rootState.projects[rootState.currentProjectId].web_url, + getters.branchName, + rootState.currentBranchId, + ), + { root: true }, + ); + } + }) + .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)); }) .catch(err => { let errMsg = __('Error committing changes. Please try again.'); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index ee759bff516..e3f504e5ab0 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -38,6 +38,7 @@ export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE'; export const SET_FILE_EOL = 'SET_FILE_EOL'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 926b6f66d78..eeb14b5490c 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -42,6 +42,8 @@ export default { renderError: data.render_error, raw: null, baseRaw: null, + html: data.html, + size: data.size, }); }, [types.SET_FILE_RAW_DATA](state, { file, raw }) { @@ -83,6 +85,11 @@ export default { mrChange, }); }, + [types.SET_FILE_VIEWMODE](state, { file, viewMode }) { + Object.assign(state.entries[file.path], { + viewMode, + }); + }, [types.DISCARD_FILE_CHANGES](state, path) { Object.assign(state.entries[path], { content: state.entries[path].raw, diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index 7f7e470c9bb..1176c040fb9 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -17,12 +17,8 @@ export default { }); }, [types.SET_DIRECTORY_DATA](state, { data, treePath }) { - Object.assign(state, { - trees: Object.assign(state.trees, { - [treePath]: { - tree: data, - }, - }), + Object.assign(state.trees[treePath], { + tree: data, }); }, [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 63e4de3b17d..05a019de54f 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -38,6 +38,9 @@ export const dataStructure = () => ({ editorColumn: 1, fileLanguage: '', eol: '', + viewMode: 'edit', + previewMode: null, + size: 0, }); export const decorateData = entity => { @@ -57,8 +60,9 @@ export const decorateData = entity => { changed = false, parentTreeUrl = '', base64 = false, - + previewMode, file_lock, + html, } = entity; return { @@ -79,8 +83,9 @@ export const decorateData = entity => { renderError, content, base64, - + previewMode, file_lock, + html, }; }; diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js index a4cd1ab099f..a1673276900 100644 --- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js +++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js @@ -1,14 +1,8 @@ +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import { decorateData, sortTree } from '../utils'; self.addEventListener('message', e => { - const { - data, - projectId, - branchId, - tempFile = false, - content = '', - base64 = false, - } = e.data; + const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; const treeList = []; let file; @@ -19,9 +13,7 @@ self.addEventListener('message', e => { if (pathSplit.length > 0) { pathSplit.reduce((pathAcc, folderName) => { const parentFolder = acc[pathAcc[pathAcc.length - 1]]; - const folderPath = `${ - parentFolder ? `${parentFolder.path}/` : '' - }${folderName}`; + const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`; const foundEntry = acc[folderPath]; if (!foundEntry) { @@ -33,9 +25,7 @@ self.addEventListener('message', e => { path: folderPath, url: `/${projectId}/tree/${branchId}/${folderPath}/`, type: 'tree', - parentTreeUrl: parentFolder - ? parentFolder.url - : `/${projectId}/tree/${branchId}/`, + parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, tempFile, changed: tempFile, opened: tempFile, @@ -70,13 +60,12 @@ self.addEventListener('message', e => { path, url: `/${projectId}/blob/${branchId}/${path}`, type: 'blob', - parentTreeUrl: fileFolder - ? fileFolder.url - : `/${projectId}/blob/${branchId}`, + parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, tempFile, changed: tempFile, content, base64, + previewMode: viewerInformationForPath(blobName), }); Object.assign(acc, { diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 172de6b3679..af47056d98f 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -45,7 +45,7 @@ return `#${this.job.runner.id}`; }, hasTimeout() { - return this.job.metadata != null && this.job.metadata.timeout_human_readable !== ''; + return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; }, timeout() { if (this.job.metadata == null) { diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 824d3f7ca09..d0050abb8e9 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -10,6 +10,7 @@ import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import DropdownUtils from './filtered_search/dropdown_utils'; import CreateLabelDropdown from './create_label'; import flash from './flash'; +import ModalStore from './boards/stores/modal_store'; export default class LabelsSelect { constructor(els, options = {}) { @@ -350,7 +351,7 @@ export default class LabelsSelect { } if ($dropdown.closest('.add-issues-modal').length) { - boardsModel = gl.issueBoards.ModalStore.store.filter; + boardsModel = ModalStore.store.filter; } if (boardsModel) { diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index de65ea15a60..914de9de940 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,7 +1,12 @@ -/* eslint-disable import/prefer-default-export */ +import $ from 'jquery'; +import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils'; + +const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); export const addClassIfElementExists = (element, className) => { if (element) { element.classList.add(className); } }; + +export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions(); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 94d03621bff..b54ecd2d543 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -7,7 +7,8 @@ * @param {String} text * @returns {String} */ -export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); +export const addDelimiter = text => + (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); /** * Returns '99+' for numbers bigger than 99. @@ -22,7 +23,8 @@ export const highCountTrim = count => (count > 99 ? '99+' : count); * @param {String} string * @requires {String} */ -export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); +export const humanize = string => + string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); /** * Adds an 's' to the end of the string when count is bigger than 0 @@ -53,7 +55,7 @@ export const slugify = str => str.trim().toLowerCase(); * @param {Number} maxLength * @returns {String} */ -export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`; +export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; /** * Capitalizes first character @@ -80,3 +82,15 @@ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, re * @param {*} string */ export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); + +/** + * Converts a sentence to lower case from the second word onwards + * e.g. Hello World => Hello world + * + * @param {*} string + */ +export const convertToSentenceCase = string => { + const splitWord = string.split(' ').map((word, index) => (index > 0 ? word.toLowerCase() : word)); + + return splitWord.join(' '); +}; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index e77318fef46..3f84f4b9499 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -7,11 +7,7 @@ import flash from './flash'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import initChangesDropdown from './init_changes_dropdown'; import bp from './breakpoints'; -import { - parseUrlPathname, - handleLocationHash, - isMetaClick, -} from './lib/utils/common_utils'; +import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils'; import { getLocationHash } from './lib/utils/url_utility'; import initDiscussionTab from './image_diff/init_discussion_tab'; import Diff from './diff'; @@ -69,11 +65,10 @@ import Notes from './notes'; let location = window.location; export default class MergeRequestTabs { - constructor({ action, setUrl, stubLocation } = {}) { const mergeRequestTabs = document.querySelector('.js-tabs-affix'); const navbar = document.querySelector('.navbar-gitlab'); - const peek = document.getElementById('peek'); + const peek = document.getElementById('js-peek'); const paddingTop = 16; this.diffsLoaded = false; @@ -109,8 +104,7 @@ export default class MergeRequestTabs { .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) .on('click', '.js-show-tab', this.showTab); - $('.merge-request-tabs a[data-toggle="tab"]') - .on('click', this.clickTab); + $('.merge-request-tabs a[data-toggle="tab"]').on('click', this.clickTab); } // Used in tests @@ -119,8 +113,7 @@ export default class MergeRequestTabs { .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) .off('click', '.js-show-tab', this.showTab); - $('.merge-request-tabs a[data-toggle="tab"]') - .off('click', this.clickTab); + $('.merge-request-tabs a[data-toggle="tab"]').off('click', this.clickTab); } destroyPipelinesView() { @@ -183,10 +176,7 @@ export default class MergeRequestTabs { scrollToElement(container) { if (location.hash) { - const offset = 0 - ( - $('.navbar-gitlab').outerHeight() + - $('.js-tabs-affix').outerHeight() - ); + const offset = 0 - ($('.navbar-gitlab').outerHeight() + $('.js-tabs-affix').outerHeight()); const $el = $(`${container} ${location.hash}:not(.match)`); if ($el.length) { $.scrollTo($el[0], { offset }); @@ -240,9 +230,13 @@ export default class MergeRequestTabs { // Turbolinks' history. // // See https://github.com/rails/turbolinks/issues/363 - window.history.replaceState({ - url: newState, - }, document.title, newState); + window.history.replaceState( + { + url: newState, + }, + document.title, + newState, + ); return newState; } @@ -258,7 +252,8 @@ export default class MergeRequestTabs { this.toggleLoading(true); - axios.get(`${source}.json`) + axios + .get(`${source}.json`) .then(({ data }) => { document.querySelector('div#commits').innerHTML = data.html; localTimeAgo($('.js-timeago', 'div#commits')); @@ -303,7 +298,8 @@ export default class MergeRequestTabs { this.toggleLoading(true); - axios.get(`${urlPathname}.json${location.search}`) + axios + .get(`${urlPathname}.json${location.search}`) .then(({ data }) => { const $container = $('#diffs'); $container.html(data.html); @@ -332,8 +328,7 @@ export default class MergeRequestTabs { cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'), suggestionSections: $(el).find('.js-file-fork-suggestion-section'), actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'), - }) - .init(); + }).init(); }); // Scroll any linked note into view @@ -388,8 +383,7 @@ export default class MergeRequestTabs { resetViewContainer() { if (this.fixedLayoutPref !== null) { - $('.content-wrapper .container-fluid') - .toggleClass('container-limited', this.fixedLayoutPref); + $('.content-wrapper .container-fluid').toggleClass('container-limited', this.fixedLayoutPref); } } @@ -438,12 +432,11 @@ export default class MergeRequestTabs { const $diffTabs = $('#diff-notes-app'); - $tabs.off('affix.bs.affix affix-top.bs.affix') + $tabs + .off('affix.bs.affix affix-top.bs.affix') .affix({ offset: { - top: () => ( - $diffTabs.offset().top - $tabs.height() - $fixedNav.height() - ), + top: () => $diffTabs.offset().top - $tabs.height() - $fixedNav.height(), }, }) .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() })) diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index e6e3a66aa20..325fa570f37 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import flash from './flash'; +import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover'; export default class Milestone { constructor() { @@ -43,4 +44,25 @@ export default class Milestone { .catch(() => flash('Error loading milestone tab')); } } + + static initDeprecationMessage() { + const deprecationMesssageContainer = document.querySelector('.js-milestone-deprecation-message'); + + if (!deprecationMesssageContainer) return; + + const deprecationMessage = deprecationMesssageContainer.querySelector('.js-milestone-deprecation-message-template').innerHTML; + const $popover = $('.js-popover-link', deprecationMesssageContainer); + const hideOnScroll = togglePopover.bind($popover, false); + + $popover.popover({ + content: deprecationMessage, + html: true, + placement: 'bottom', + }) + .on('mouseenter', mouseenter) + .on('mouseleave', debouncedMouseleave()) + .on('show.bs.popover', () => { + window.addEventListener('scroll', hideOnScroll, { once: true }); + }); + } } diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index add07c156a4..d0a2b27b0e6 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -6,6 +6,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; import { timeFor } from './lib/utils/datetime_utility'; +import ModalStore from './boards/stores/modal_store'; export default class MilestoneSelect { constructor(currentProject, els, options = {}) { @@ -94,10 +95,10 @@ export default class MilestoneSelect { if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } - $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); + $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active'); }), renderRow: milestone => ` - <li data-milestone-id="${milestone.name}"> + <li data-milestone-id="${_.escape(milestone.name)}"> <a href='#' class='dropdown-menu-milestone-link'> ${_.escape(milestone.title)} </a> @@ -125,7 +126,6 @@ export default class MilestoneSelect { return milestone.id; } }, - isSelected: milestone => milestone.name === selectedMilestone, hidden: () => { $selectBox.hide(); // display:block overrides the hide-collapse rule @@ -137,7 +137,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; } $('a.is-active', $el).removeClass('is-active'); - $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); + $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: (clickEvent) => { @@ -158,13 +158,14 @@ export default class MilestoneSelect { const isMRIndex = (page === page && page === 'projects:merge_requests:index'); const isSelecting = (selected.name !== selectedMilestone); selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault; + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); return; } if ($dropdown.closest('.add-issues-modal').length) { - boardsStore = gl.issueBoards.ModalStore.store.filter; + boardsStore = ModalStore.store.filter; } if (boardsStore) { diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 04d546fafa0..f93b1da4f58 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -1,8 +1,10 @@ <script> import { scaleLinear, scaleTime } from 'd3-scale'; import { axisLeft, axisBottom } from 'd3-axis'; +import _ from 'underscore'; import { max, extent } from 'd3-array'; import { select } from 'd3-selection'; +import GraphAxis from './graph/axis.vue'; import GraphLegend from './graph/legend.vue'; import GraphFlag from './graph/flag.vue'; import GraphDeployment from './graph/deployment.vue'; @@ -18,10 +20,11 @@ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select } export default { components: { - GraphLegend, + GraphAxis, GraphFlag, GraphDeployment, GraphPath, + GraphLegend, }, mixins: [MonitoringMixin], props: { @@ -138,7 +141,7 @@ export default { this.legendTitle = query.label || 'Average'; this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right; this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - this.baseGraphHeight = this.graphHeight; + this.baseGraphHeight = this.graphHeight - 50; this.baseGraphWidth = this.graphWidth; // pixel offsets inside the svg and outside are not 1:1 @@ -177,10 +180,8 @@ export default { this.graphHeightOffset, ); - if (!this.showLegend) { - this.baseGraphHeight -= 50; - } else if (this.timeSeries.length > 3) { - this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; + if (_.findWhere(this.timeSeries, { renderCanary: true })) { + this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true })); } const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); @@ -251,17 +252,13 @@ export default { class="y-axis" transform="translate(70, 20)" /> - <graph-legend + <graph-axis :graph-width="graphWidth" :graph-height="graphHeight" :margin="margin" :measurements="measurements" - :legend-title="legendTitle" :y-axis-label="yAxisLabel" - :time-series="timeSeries" :unit-of-display="unitOfDisplay" - :current-data-index="currentDataIndex" - :show-legend-group="showLegend" /> <svg class="graph-data" @@ -306,5 +303,10 @@ export default { :deployment-flag-data="deploymentFlagData" /> </div> + <graph-legend + v-if="showLegend" + :legend-title="legendTitle" + :time-series="timeSeries" + /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue new file mode 100644 index 00000000000..fc4b3689dfd --- /dev/null +++ b/app/assets/javascripts/monitoring/components/graph/axis.vue @@ -0,0 +1,142 @@ +<script> +import { convertToSentenceCase } from '~/lib/utils/text_utility'; +import { s__ } from '~/locale'; + +export default { + props: { + graphWidth: { + type: Number, + required: true, + }, + graphHeight: { + type: Number, + required: true, + }, + margin: { + type: Object, + required: true, + }, + measurements: { + type: Object, + required: true, + }, + yAxisLabel: { + type: String, + required: true, + }, + unitOfDisplay: { + type: String, + required: true, + }, + }, + data() { + return { + yLabelWidth: 0, + yLabelHeight: 0, + }; + }, + computed: { + textTransform() { + const yCoordinate = + (this.graphHeight - + this.margin.top + + this.measurements.axisLabelLineOffset) / + 2 || 0; + + return `translate(15, ${yCoordinate}) rotate(-90)`; + }, + + rectTransform() { + const yCoordinate = + (this.graphHeight - + this.margin.top + + this.measurements.axisLabelLineOffset) / + 2 + + this.yLabelWidth / 2 || 0; + + return `translate(0, ${yCoordinate}) rotate(-90)`; + }, + + xPosition() { + return ( + (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - + this.margin.right || 0 + ); + }, + + yPosition() { + return ( + this.graphHeight - + this.margin.top + + this.measurements.axisLabelLineOffset || 0 + ); + }, + + yAxisLabelSentenceCase() { + return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`; + }, + + timeString() { + return s__('PrometheusDashboard|Time'); + }, + }, + mounted() { + this.$nextTick(() => { + const bbox = this.$refs.ylabel.getBBox(); + this.yLabelWidth = bbox.width + 10; // Added some padding + this.yLabelHeight = bbox.height + 5; + }); + }, +}; +</script> +<template> + <g class="axis-label-container"> + <line + class="label-x-axis-line" + stroke="#000000" + stroke-width="1" + x1="10" + :y1="yPosition" + :x2="graphWidth + 20" + :y2="yPosition" + /> + <line + class="label-y-axis-line" + stroke="#000000" + stroke-width="1" + x1="10" + y1="0" + :x2="10" + :y2="yPosition" + /> + <rect + class="rect-axis-text" + :transform="rectTransform" + :width="yLabelWidth" + :height="yLabelHeight" + /> + <text + class="label-axis-text y-label-text" + text-anchor="middle" + :transform="textTransform" + ref="ylabel" + > + {{ yAxisLabelSentenceCase }} + </text> + <rect + class="rect-axis-text" + :x="xPosition + 60" + :y="graphHeight - 80" + width="35" + height="50" + /> + <text + class="label-axis-text x-label-text" + :x="xPosition + 60" + :y="yPosition" + dy=".35em" + > + {{ timeString }} + </text> + </g> +</template> diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index 906c7c51f52..b8202e25685 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -1,11 +1,13 @@ <script> import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; import { formatRelevantDigits } from '../../../lib/utils/number_utils'; -import icon from '../../../vue_shared/components/icon.vue'; +import Icon from '../../../vue_shared/components/icon.vue'; +import TrackLine from './track_line.vue'; export default { components: { - icon, + Icon, + TrackLine, }, props: { currentXCoordinate: { @@ -107,11 +109,6 @@ export default { } return `series ${index + 1}`; }, - strokeDashArray(type) { - if (type === 'dashed') return '6, 3'; - if (type === 'dotted') return '3, 3'; - return null; - }, }, }; </script> @@ -160,28 +157,13 @@ export default { </div> </div> <div class="popover-content"> - <table> + <table class="prometheus-table"> <tr v-for="(series, index) in timeSeries" :key="index" > - <td> - <svg - width="15" - height="6" - > - <line - :stroke="series.lineColor" - :stroke-dasharray="strokeDashArray(series.lineStyle)" - stroke-width="4" - x1="0" - x2="15" - y1="2" - y2="2" - /> - </svg> - </td> - <td>{{ seriesMetricLabel(index, series) }}</td> + <track-line :track="series"/> + <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td> <td> <strong>{{ seriesMetricValue(series) }}</strong> </td> diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index a7a058a9203..da9280cf1f1 100644 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -1,204 +1,72 @@ <script> -import { formatRelevantDigits } from '../../../lib/utils/number_utils'; +import TrackLine from './track_line.vue'; +import TrackInfo from './track_info.vue'; export default { + components: { + TrackLine, + TrackInfo, + }, props: { - graphWidth: { - type: Number, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - margin: { - type: Object, - required: true, - }, - measurements: { - type: Object, - required: true, - }, legendTitle: { type: String, required: true, }, - yAxisLabel: { - type: String, - required: true, - }, timeSeries: { type: Array, required: true, }, - unitOfDisplay: { - type: String, - required: true, - }, - currentDataIndex: { - type: Number, - required: true, - }, - showLegendGroup: { - type: Boolean, - required: false, - default: true, - }, - }, - data() { - return { - yLabelWidth: 0, - yLabelHeight: 0, - seriesXPosition: 0, - metricUsageXPosition: 0, - }; - }, - computed: { - textTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0; - - return `translate(15, ${yCoordinate}) rotate(-90)`; - }, - rectTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 + - this.yLabelWidth / 2 || 0; - - return `translate(0, ${yCoordinate}) rotate(-90)`; - }, - xPosition() { - return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0; - }, - yPosition() { - return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0; - }, - }, - mounted() { - this.$nextTick(() => { - const bbox = this.$refs.ylabel.getBBox(); - this.metricUsageXPosition = 0; - this.seriesXPosition = 0; - if (this.$refs.legendTitleSvg != null) { - this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width; - } - if (this.$refs.seriesTitleSvg != null) { - this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width; - } - this.yLabelWidth = bbox.width + 10; // Added some padding - this.yLabelHeight = bbox.height + 5; - }); }, methods: { - translateLegendGroup(index) { - return `translate(0, ${12 * index})`; - }, - formatMetricUsage(series) { - const value = - series.values[this.currentDataIndex] && series.values[this.currentDataIndex].value; - if (isNaN(value)) { - return '-'; - } - return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`; - }, - createSeriesString(index, series) { - if (series.metricTag) { - return `${series.metricTag} ${this.formatMetricUsage(series)}`; - } - return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`; - }, - strokeDashArray(type) { - if (type === 'dashed') return '6, 3'; - if (type === 'dotted') return '3, 3'; - return null; + isStable(track) { + return { + 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary, + }; }, }, }; </script> <template> - <g class="axis-label-container"> - <line - class="label-x-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - :y1="yPosition" - :x2="graphWidth + 20" - :y2="yPosition" - /> - <line - class="label-y-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - y1="0" - :x2="10" - :y2="yPosition" - /> - <rect - class="rect-axis-text" - :transform="rectTransform" - :width="yLabelWidth" - :height="yLabelHeight" - /> - <text - class="label-axis-text y-label-text" - text-anchor="middle" - :transform="textTransform" - ref="ylabel" - > - {{ yAxisLabel }} - </text> - <rect - class="rect-axis-text" - :x="xPosition + 60" - :y="graphHeight - 80" - width="35" - height="50" - /> - <text - class="label-axis-text x-label-text" - :x="xPosition + 60" - :y="yPosition" - dy=".35em" - > - Time - </text> - <template v-if="showLegendGroup"> - <g - class="legend-group" + <div class="prometheus-graph-legends prepend-left-10"> + <table class="prometheus-table"> + <tr v-for="(series, index) in timeSeries" :key="index" - :transform="translateLegendGroup(index)" + v-if="series.shouldRenderLegend" + :class="isStable(series)" > - <line - :stroke="series.lineColor" - :stroke-width="measurements.legends.height" - :stroke-dasharray="strokeDashArray(series.lineStyle)" - :x1="measurements.legends.offsetX" - :x2="measurements.legends.offsetX + measurements.legends.width" - :y1="graphHeight - measurements.legends.offsetY" - :y2="graphHeight - measurements.legends.offsetY" - /> - <text - v-if="timeSeries.length > 1" - class="legend-metric-title" - ref="legendTitleSvg" - x="38" - :y="graphHeight - 30" - > - {{ createSeriesString(index, series) }} - </text> - <text - v-else + <td> + <strong v-if="series.renderCanary">{{ series.trackName }}</strong> + </td> + <track-line :track="series" /> + <td class="legend-metric-title" - ref="legendTitleSvg" - x="38" - :y="graphHeight - 30" - > - {{ legendTitle }} {{ formatMetricUsage(series) }} - </text> - </g> - </template> - </g> + v-if="timeSeries.length > 1"> + <track-info + :track="series" + v-if="series.metricTag" /> + <track-info + v-else + :track="series"> + <strong>{{ legendTitle }}</strong> series {{ index + 1 }} + </track-info> + </td> + <td v-else> + <track-info :track="series"> + <strong>{{ legendTitle }}</strong> + </track-info> + </td> + <template v-for="(track, trackIndex) in series.tracksLegend"> + <track-line + :track="track" + :key="`track-line-${trackIndex}`"/> + <td :key="`track-info-${trackIndex}`"> + <track-info + class="legend-metric-title" + :track="track" /> + </td> + </template> + </tr> + </table> + </div> </template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue new file mode 100644 index 00000000000..ec1c2222af9 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/graph/track_info.vue @@ -0,0 +1,29 @@ +<script> +import { formatRelevantDigits } from '~/lib/utils/number_utils'; + +export default { + name: 'TrackInfo', + props: { + track: { + type: Object, + required: true, + }, + }, + computed: { + summaryMetrics() { + return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits( + this.track.max, + )}`; + }, + }, +}; +</script> +<template> + <span> + <slot> + <strong> {{ track.metricTag }} </strong> + </slot> + {{ summaryMetrics }} + </span> +</template> + diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue new file mode 100644 index 00000000000..79b322e2e42 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/graph/track_line.vue @@ -0,0 +1,36 @@ +<script> +export default { + name: 'TrackLine', + props: { + track: { + type: Object, + required: true, + }, + }, + computed: { + stylizedLine() { + if (this.track.lineStyle === 'dashed') return '6, 3'; + if (this.track.lineStyle === 'dotted') return '3, 3'; + return null; + }, + }, +}; +</script> +<template> + <td> + <svg + width="15" + height="6"> + <line + :stroke-dasharray="stylizedLine" + :stroke="track.lineColor" + stroke-width="4" + :x1="0" + :x2="15" + :y1="2" + :y2="2" + /> + </svg> + </td> +</template> + diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 854636e9a89..535c415cd6d 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -1,7 +1,7 @@ import _ from 'underscore'; function sortMetrics(metrics) { - return _.chain(metrics).sortBy('weight').sortBy('title').value(); + return _.chain(metrics).sortBy('title').sortBy('weight').value(); } function normalizeMetrics(metrics) { diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index b5b8e3c255d..8a93c7e6bae 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -1,10 +1,21 @@ import _ from 'underscore'; import { scaleLinear, scaleTime } from 'd3-scale'; import { line, area, curveLinear } from 'd3-shape'; -import { extent, max } from 'd3-array'; +import { extent, max, sum } from 'd3-array'; import { timeMinute } from 'd3-time'; - -const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute }; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +const d3 = { + scaleLinear, + scaleTime, + line, + area, + curveLinear, + extent, + max, + timeMinute, + sum, +}; const defaultColorPalette = { blue: ['#1f78d1', '#8fbce8'], @@ -20,6 +31,8 @@ const defaultStyleOrder = ['solid', 'dashed', 'dotted']; function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) { let usedColors = []; + let renderCanary = false; + const timeSeriesParsed = []; function pickColor(name) { let pick; @@ -38,16 +51,23 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom return defaultColorPalette[pick]; } - return query.result.map((timeSeries, timeSeriesNumber) => { + query.result.forEach((timeSeries, timeSeriesNumber) => { let metricTag = ''; let lineColor = ''; let areaColor = ''; + let shouldRenderLegend = true; + const timeSeriesValues = timeSeries.values.map(d => d.value); + const maximumValue = d3.max(timeSeriesValues); + const accum = d3.sum(timeSeriesValues); + const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable'); + + if (trackName === 'Canary') { + renderCanary = true; + } - const timeSeriesScaleX = d3.scaleTime() - .range([0, graphWidth - 70]); + const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]); - const timeSeriesScaleY = d3.scaleLinear() - .range([graphHeight - graphHeightOffset, 0]); + const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]); timeSeriesScaleX.domain(xDom); timeSeriesScaleX.ticks(d3.timeMinute, 60); @@ -55,13 +75,15 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom const defined = d => !isNaN(d.value) && d.value != null; - const lineFunction = d3.line() + const lineFunction = d3 + .line() .defined(defined) .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate .x(d => timeSeriesScaleX(d.time)) .y(d => timeSeriesScaleY(d.value)); - const areaFunction = d3.area() + const areaFunction = d3 + .area() .defined(defined) .curve(d3.curveLinear) .x(d => timeSeriesScaleX(d.time)) @@ -69,38 +91,62 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom .y1(d => timeSeriesScaleY(d.value)); const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; - const seriesCustomizationData = query.series != null && - _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); + const seriesCustomizationData = + query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); if (seriesCustomizationData) { metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; [lineColor, areaColor] = pickColor(seriesCustomizationData.color); + shouldRenderLegend = false; } else { metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; [lineColor, areaColor] = pickColor(); + if (timeSeriesParsed.length > 1) { + shouldRenderLegend = false; + } } - if (query.track) { - metricTag += ` - ${query.track}`; + if (!shouldRenderLegend) { + if (!timeSeriesParsed[0].tracksLegend) { + timeSeriesParsed[0].tracksLegend = []; + } + timeSeriesParsed[0].tracksLegend.push({ + max: maximumValue, + average: accum / timeSeries.values.length, + lineStyle, + lineColor, + metricTag, + }); } - return { + timeSeriesParsed.push({ linePath: lineFunction(timeSeries.values), areaPath: areaFunction(timeSeries.values), timeSeriesScaleX, values: timeSeries.values, + max: maximumValue, + average: accum / timeSeries.values.length, lineStyle, lineColor, areaColor, metricTag, - }; + trackName, + shouldRenderLegend, + renderCanary, + }); }); + + return timeSeriesParsed; } export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { - const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat( - query.result.reduce((allResults, result) => allResults.concat(result.values), []), - ), []); + const allValues = queries.reduce( + (allQueryResults, query) => + allQueryResults.concat( + query.result.reduce((allResults, result) => allResults.concat(result.values), []), + ), + [], + ); const xDom = d3.extent(allValues, d => d.time); const yDom = [0, d3.max(allValues.map(d => d.value))]; diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 096c4ef5f31..e3c5bf06b3d 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -13,8 +13,11 @@ export default function initMrNotes() { data() { const notesDataset = document.getElementById('js-vue-mr-discussions') .dataset; + const noteableData = JSON.parse(notesDataset.noteableData); + noteableData.noteableType = notesDataset.noteableType; + return { - noteableData: JSON.parse(notesDataset.noteableData), + noteableData, currentUserData: JSON.parse(notesDataset.currentUserData), notesData: JSON.parse(notesDataset.notesData), }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b0573510ff9..2121907dff0 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -19,7 +19,6 @@ import AjaxCache from '~/lib/utils/ajax_cache'; import Vue from 'vue'; import syntaxHighlight from '~/syntax_highlight'; import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; -import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { getLocationHash } from './lib/utils/url_utility'; import Flash from './flash'; @@ -198,6 +197,8 @@ export default class Notes { ); this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff); + this.$wrapperEl.on('click', '.js-toggle-lazy-diff-retry-button', this.onClickRetryLazyLoad.bind(this)); + // fetch notes when tab becomes visible this.$wrapperEl.on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data @@ -244,6 +245,7 @@ export default class Notes { this.$wrapperEl.off('click', '.js-comment-resolve-button'); this.$wrapperEl.off('click', '.system-note-commit-list-toggler'); this.$wrapperEl.off('click', '.js-toggle-lazy-diff'); + this.$wrapperEl.off('click', '.js-toggle-lazy-diff-retry-button'); this.$wrapperEl.off('ajax:success', '.js-main-target-form'); this.$wrapperEl.off('ajax:success', '.js-discussion-note-form'); this.$wrapperEl.off('ajax:complete', '.js-main-target-form'); @@ -1431,16 +1433,15 @@ export default class Notes { syntaxHighlight(fileHolder); } - static renderDiffError($container) { - $container.find('.line_content').html( - $(` - <div class="nothing-here-block"> - ${__( - 'Unable to load the diff.', - )} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>? - </div> - `), - ); + onClickRetryLazyLoad(e) { + const $retryButton = $(e.currentTarget); + + $retryButton.prop('disabled', true); + + return this.loadLazyDiff(e) + .then(() => { + $retryButton.prop('disabled', false); + }); } loadLazyDiff(e) { @@ -1449,20 +1450,35 @@ export default class Notes { $container.find('.js-toggle-lazy-diff').removeClass('js-toggle-lazy-diff'); - const tableEl = $container.find('tbody'); - if (tableEl.length === 0) return; + const $tableEl = $container.find('tbody'); + if ($tableEl.length === 0) return; const fileHolder = $container.find('.file-holder'); const url = fileHolder.data('linesPath'); - axios + const $errorContainer = $container.find('.js-error-lazy-load-diff'); + const $successContainer = $container.find('.js-success-lazy-load'); + + /** + * We only fetch resolved discussions. + * Unresolved discussions don't have an endpoint being provided. + */ + if (url) { + return axios .get(url) .then(({ data }) => { + // Reset state in case last request returned error + $successContainer.removeClass('hidden'); + $errorContainer.addClass('hidden'); + Notes.renderDiffContent($container, data); }) .catch(() => { - Notes.renderDiffError($container); + $successContainer.addClass('hidden'); + $errorContainer.removeClass('hidden'); }); + } + return Promise.resolve(); } toggleCommitList(e) { diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 648fa6ff804..396a675b4ac 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -317,10 +317,10 @@ Please check your network connection and try again.`; <note-signed-out-widget v-if="!isLoggedIn" /> <discussion-locked-widget issuable-type="issue" - v-else-if="!canCreateNote" + v-else-if="isLocked(getNoteableData) && !canCreateNote" /> <ul - v-else + v-else-if="canCreateNote" class="notes notes-form timeline"> <li class="timeline-entry"> <div class="timeline-entry-inner"> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index a7e2d857013..626b0799581 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -40,6 +40,10 @@ export default { type: Boolean, required: true, }, + canAwardEmoji: { + type: Boolean, + required: true, + }, canDelete: { type: Boolean, required: true, @@ -74,9 +78,6 @@ export default { shouldShowActionsDropdown() { return this.currentUserId && (this.canEdit || this.canReportAsAbuse); }, - canAddAwardEmoji() { - return this.currentUserId; - }, isAuthoredByCurrentUser() { return this.authorId === this.currentUserId; }, @@ -149,7 +150,7 @@ export default { </button> </div> <div - v-if="canAddAwardEmoji" + v-if="canAwardEmoji" class="note-actions-item"> <a v-tooltip diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 6cb8229e268..e8fd155a1ee 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -28,6 +28,10 @@ export default { type: Number, required: true, }, + canAwardEmoji: { + type: Boolean, + required: true, + }, }, computed: { ...mapGetters(['getUserData']), @@ -67,9 +71,6 @@ export default { isAuthoredByMe() { return this.noteAuthorId === this.getUserData.id; }, - isLoggedIn() { - return this.getUserData.id; - }, }, created() { this.emojiSmiling = emojiSmiling; @@ -156,7 +157,7 @@ export default { return title; }, handleAward(awardName) { - if (!this.isLoggedIn) { + if (!this.canAwardEmoji) { return; } @@ -208,7 +209,7 @@ export default { </span> </button> <div - v-if="isLoggedIn" + v-if="canAwardEmoji" class="award-menu-holder"> <button v-tooltip diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 069f94c5845..0cb626c14f4 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -112,6 +112,7 @@ export default { :note-author-id="note.author.id" :awards="note.award_emoji" :toggle-award-path="note.toggle_award_path" + :can-award-emoji="note.current_user.can_award_emoji" /> <note-attachment v-if="note.attachment" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 3554027d2b4..566f5c68e66 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -177,6 +177,7 @@ export default { :note-id="note.id" :access-level="note.human_access" :can-edit="note.current_user.can_edit" + :can-award-emoji="note.current_user.can_award_emoji" :can-delete="note.current_user.can_edit" :can-report-as-abuse="canReportAsAbuse" :report-abuse-path="note.report_abuse_path" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 5bd81c7cad6..ebfc827ac57 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -49,16 +49,7 @@ export default { computed: { ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), noteableType() { - // FIXME -- @fatihacet Get this from JSON data. - const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE, EPIC_NOTEABLE_TYPE } = constants; - - if (this.noteableData.noteableType === EPIC_NOTEABLE_TYPE) { - return EPIC_NOTEABLE_TYPE; - } - - return this.noteableData.merge_params - ? MERGE_REQUEST_NOTEABLE_TYPE - : ISSUE_NOTEABLE_TYPE; + return this.noteableData.noteableType; }, allNotes() { if (this.isLoading) { diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 68f8cb1cf1e..c4de4826eda 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -14,3 +14,9 @@ export const EPIC_NOTEABLE_TYPE = 'epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; + +export const NOTEABLE_TYPE_MAPPING = { + Issue: ISSUE_NOTEABLE_TYPE, + MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, + Epic: EPIC_NOTEABLE_TYPE, +}; diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js index 5bf8216a1f3..b68543d71c8 100644 --- a/app/assets/javascripts/notes/mixins/noteable.js +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -9,16 +9,7 @@ export default { }, computed: { noteableType() { - switch (this.note.noteable_type) { - case 'MergeRequest': - return constants.MERGE_REQUEST_NOTEABLE_TYPE; - case 'Issue': - return constants.ISSUE_NOTEABLE_TYPE; - case 'Epic': - return constants.EPIC_NOTEABLE_TYPE; - default: - return ''; - } + return constants.NOTEABLE_TYPE_MAPPING[this.note.noteable_type]; }, }, }; diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js index 397149aaa9e..8b529585898 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js @@ -6,4 +6,6 @@ document.addEventListener('DOMContentLoaded', () => { new Milestone(); // eslint-disable-line no-new new Sidebar(); // eslint-disable-line no-new new MountMilestoneSidebar(); // eslint-disable-line no-new + + Milestone.initDeprecationMessage(); }); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index d149b307e7f..914f804fdd3 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -5,6 +5,7 @@ import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, + isGroupDecendent: true, }); projectSelect(); }); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index a5cc1f34b63..1600faa3611 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -5,6 +5,7 @@ import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, + isGroupDecendent: true, }); projectSelect(); }); diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js index 88f40b5278e..74cc4ba42c1 100644 --- a/app/assets/javascripts/pages/groups/milestones/show/index.js +++ b/app/assets/javascripts/pages/groups/milestones/show/index.js @@ -1,3 +1,8 @@ import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; +import Milestone from '~/milestone'; -document.addEventListener('DOMContentLoaded', initMilestonesShow); +document.addEventListener('DOMContentLoaded', () => { + initMilestonesShow(); + + Milestone.initDeprecationMessage(); +}); diff --git a/app/assets/javascripts/pages/groups/settings/badges/index.js b/app/assets/javascripts/pages/groups/settings/badges/index.js new file mode 100644 index 00000000000..74e96ee4a8f --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/badges/index.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { GROUP_BADGE } from '~/badges/constants'; +import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; + +Vue.use(Translate); + +document.addEventListener('DOMContentLoaded', () => { + mountBadgeSettings(GROUP_BADGE); +}); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index be37df36be8..628913483c6 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -1,12 +1,12 @@ import initSettingsPanels from '~/settings_panels'; import setupProjectEdit from '~/project_edit'; import initConfirmDangerModal from '~/confirm_danger_modal'; -import ProjectNew from '../shared/project_new'; +import initProjectLoadingSpinner from '../shared/save_project_loader'; import projectAvatar from '../shared/project_avatar'; import initProjectPermissionsSettings from '../shared/permissions'; document.addEventListener('DOMContentLoaded', () => { - new ProjectNew(); // eslint-disable-line no-new + initProjectLoadingSpinner(); setupProjectEdit(); // Initialize expandable settings panels initSettingsPanels(); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index ea6fd961393..7db644e2477 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -1,9 +1,9 @@ -import ProjectNew from '../shared/project_new'; +import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectVisibilitySelector from '../../../project_visibility'; import initProjectNew from '../../../projects/project_new'; document.addEventListener('DOMContentLoaded', () => { - new ProjectNew(); // eslint-disable-line no-new + initProjectLoadingSpinner(); initProjectVisibilitySelector(); initProjectNew.bindEvents(); }); diff --git a/app/assets/javascripts/pages/projects/settings/badges/index/index.js b/app/assets/javascripts/pages/projects/settings/badges/index/index.js new file mode 100644 index 00000000000..30469550866 --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/badges/index/index.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { PROJECT_BADGE } from '~/badges/constants'; +import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; + +Vue.use(Translate); + +document.addEventListener('DOMContentLoaded', () => { + mountBadgeSettings(PROJECT_BADGE); +}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js new file mode 100644 index 00000000000..ffc84dc106b --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js @@ -0,0 +1,3 @@ +import initForm from '../form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js new file mode 100644 index 00000000000..a5c17ab322c --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -0,0 +1,19 @@ +/* eslint-disable no-new */ + +import ProtectedTagCreate from '~/protected_tags/protected_tag_create'; +import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list'; +import initSettingsPanels from '~/settings_panels'; +import initDeployKeys from '~/deploy_keys'; +import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; +import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list'; +import DueDateSelectors from '~/due_date_select'; + +export default () => { + new ProtectedTagCreate(); + new ProtectedTagEditList(); + initDeployKeys(); + initSettingsPanels(); + new ProtectedBranchCreate(); // eslint-disable-line no-new + new ProtectedBranchEditList(); // eslint-disable-line no-new + new DueDateSelectors(); +}; diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index 788d86d1192..ffc84dc106b 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,17 +1,3 @@ -/* eslint-disable no-new */ +import initForm from '../form'; -import ProtectedTagCreate from '~/protected_tags/protected_tag_create'; -import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list'; -import initSettingsPanels from '~/settings_panels'; -import initDeployKeys from '~/deploy_keys'; -import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; -import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list'; - -document.addEventListener('DOMContentLoaded', () => { - new ProtectedTagCreate(); - new ProtectedTagEditList(); - initDeployKeys(); - initSettingsPanels(); - new ProtectedBranchCreate(); // eslint-disable-line no-new - new ProtectedBranchEditList(); // eslint-disable-line no-new -}); +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/shared/project_new.js b/app/assets/javascripts/pages/projects/shared/project_new.js deleted file mode 100644 index 56d5574aa2f..00000000000 --- a/app/assets/javascripts/pages/projects/shared/project_new.js +++ /dev/null @@ -1,152 +0,0 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, prefer-template, prefer-arrow-callback*/ - -import $ from 'jquery'; -import VisibilitySelect from '../../../visibility_select'; - -function highlightChanges($elm) { - $elm.addClass('highlight-changes'); - setTimeout(() => $elm.removeClass('highlight-changes'), 10); -} - -export default class ProjectNew { - constructor() { - this.toggleSettings = this.toggleSettings.bind(this); - this.$selects = $('.features select'); - this.$repoSelects = this.$selects.filter('.js-repo-select'); - this.$projectSelects = this.$selects.not('.js-repo-select'); - - $('.project-edit-container').on('ajax:before', () => { - $('.project-edit-container').hide(); - return $('.save-project-loader').show(); - }); - - this.initVisibilitySelect(); - - this.toggleSettings(); - this.toggleSettingsOnclick(); - this.toggleRepoVisibility(); - } - - initVisibilitySelect() { - const visibilityContainer = document.querySelector('.js-visibility-select'); - if (!visibilityContainer) return; - const visibilitySelect = new VisibilitySelect(visibilityContainer); - visibilitySelect.init(); - - const $visibilitySelect = $(visibilityContainer).find('select'); - let projectVisibility = $visibilitySelect.val(); - const PROJECT_VISIBILITY_PRIVATE = '0'; - - $visibilitySelect.on('change', () => { - const newProjectVisibility = $visibilitySelect.val(); - - if (projectVisibility !== newProjectVisibility) { - this.$projectSelects.each((idx, select) => { - const $select = $(select); - const $options = $select.find('option'); - const values = $.map($options, e => e.value); - - // if switched to "private", limit visibility options - if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) { - if ($select.val() !== values[0] && $select.val() !== values[1]) { - $select.val(values[1]).trigger('change'); - highlightChanges($select); - } - $options.slice(2).disable(); - } - - // if switched from "private", increase visibility for non-disabled options - if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) { - $options.enable(); - if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) { - $select.val(values[values.length - 1]).trigger('change'); - highlightChanges($select); - } - } - }); - - projectVisibility = newProjectVisibility; - } - }); - } - - toggleSettings() { - this.$selects.each(function () { - var $select = $(this); - var className = $select.data('field') - .replace(/_/g, '-') - .replace('access-level', 'feature'); - ProjectNew._showOrHide($select, '.' + className); - }); - } - - toggleSettingsOnclick() { - this.$selects.on('change', this.toggleSettings); - } - - static _showOrHide(checkElement, container) { - const $container = $(container); - - if ($(checkElement).val() !== '0') { - return $container.show(); - } - return $container.hide(); - } - - toggleRepoVisibility() { - var $repoAccessLevel = $('.js-repo-access-level select'); - var $lfsEnabledOption = $('.js-lfs-enabled select'); - var containerRegistry = document.querySelectorAll('.js-container-registry')[0]; - var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled'); - var prevSelectedVal = parseInt($repoAccessLevel.val(), 10); - - this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']") - .nextAll() - .hide(); - - $repoAccessLevel - .off('change') - .on('change', function () { - var selectedVal = parseInt($repoAccessLevel.val(), 10); - - this.$repoSelects.each(function () { - var $this = $(this); - var repoSelectVal = parseInt($this.val(), 10); - - $this.find('option').enable(); - - if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) { - $this.val(selectedVal).trigger('change'); - highlightChanges($this); - } - - $this.find("option[value='" + selectedVal + "']").nextAll().disable(); - }); - - if (selectedVal) { - this.$repoSelects.removeClass('disabled'); - - if ($lfsEnabledOption.length) { - $lfsEnabledOption.removeClass('disabled'); - highlightChanges($lfsEnabledOption); - } - if (containerRegistry) { - containerRegistry.style.display = ''; - } - } else { - this.$repoSelects.addClass('disabled'); - - if ($lfsEnabledOption.length) { - $lfsEnabledOption.val('false').addClass('disabled'); - highlightChanges($lfsEnabledOption); - } - if (containerRegistry) { - containerRegistry.style.display = 'none'; - containerRegistryCheckbox.checked = false; - } - } - - prevSelectedVal = selectedVal; - }.bind(this)); - } -} diff --git a/app/assets/javascripts/pages/projects/shared/save_project_loader.js b/app/assets/javascripts/pages/projects/shared/save_project_loader.js new file mode 100644 index 00000000000..aa3589ac88d --- /dev/null +++ b/app/assets/javascripts/pages/projects/shared/save_project_loader.js @@ -0,0 +1,12 @@ +import $ from 'jquery'; + +export default function initProjectLoadingSpinner() { + const $formContainer = $('.project-edit-container'); + const $loadingSpinner = $('.save-project-loader'); + + // show loading spinner when saving + $formContainer.on('ajax:before', () => { + $formContainer.hide(); + $loadingSpinner.show(); + }); +} diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js index a134599cb04..c35b9c30058 100644 --- a/app/assets/javascripts/pages/projects/snippets/show/index.js +++ b/app/assets/javascripts/pages/projects/snippets/show/index.js @@ -1,11 +1,13 @@ import initNotes from '~/init_notes'; import ZenMode from '~/zen_mode'; -import LineHighlighter from '../../../../line_highlighter'; -import BlobViewer from '../../../../blob/viewer'; +import LineHighlighter from '~/line_highlighter'; +import BlobViewer from '~/blob/viewer'; +import snippetEmbed from '~/snippet/snippet_embed'; document.addEventListener('DOMContentLoaded', () => { new LineHighlighter(); // eslint-disable-line no-new new BlobViewer(); // eslint-disable-line no-new initNotes(); new ZenMode(); // eslint-disable-line no-new + snippetEmbed(); }); diff --git a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js index 08f0afdcce3..d321892d2d2 100644 --- a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js +++ b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js @@ -5,7 +5,7 @@ import AccessorUtilities from '~/lib/utils/accessor'; * Does that setting the current selected tab in the localStorage */ export default class SigninTabsMemoizer { - constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) { + constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.new-session-tabs' } = {}) { this.currentTabKey = currentTabKey; this.tabSelector = tabSelector; this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); diff --git a/app/assets/javascripts/pages/shared/mount_badge_settings.js b/app/assets/javascripts/pages/shared/mount_badge_settings.js new file mode 100644 index 00000000000..1397c0834ff --- /dev/null +++ b/app/assets/javascripts/pages/shared/mount_badge_settings.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import BadgeSettings from '~/badges/components/badge_settings.vue'; +import store from '~/badges/store'; + +export default kind => { + const badgeSettingsElement = document.getElementById('badge-settings'); + + store.dispatch('loadBadges', { + kind, + apiEndpointUrl: badgeSettingsElement.dataset.apiEndpointUrl, + docsUrl: badgeSettingsElement.dataset.docsUrl, + }); + + return new Vue({ + el: badgeSettingsElement, + store, + components: { + BadgeSettings, + }, + render(createElement) { + return createElement(BadgeSettings); + }, + }); +}; diff --git a/app/assets/javascripts/pages/snippets/show/index.js b/app/assets/javascripts/pages/snippets/show/index.js index f548b9fad65..26936110402 100644 --- a/app/assets/javascripts/pages/snippets/show/index.js +++ b/app/assets/javascripts/pages/snippets/show/index.js @@ -1,11 +1,13 @@ -import LineHighlighter from '../../../line_highlighter'; -import BlobViewer from '../../../blob/viewer'; -import ZenMode from '../../../zen_mode'; -import initNotes from '../../../init_notes'; +import LineHighlighter from '~/line_highlighter'; +import BlobViewer from '~/blob/viewer'; +import ZenMode from '~/zen_mode'; +import initNotes from '~/init_notes'; +import snippetEmbed from '~/snippet/snippet_embed'; document.addEventListener('DOMContentLoaded', () => { new LineHighlighter(); // eslint-disable-line no-new new BlobViewer(); // eslint-disable-line no-new initNotes(); new ZenMode(); // eslint-disable-line no-new + snippetEmbed(); }); diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js index 3ebfaa87a4e..bc71911ae35 100644 --- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js +++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js @@ -10,29 +10,25 @@ export default class PerformanceBarService { } static registerInterceptor(peekUrl, callback) { - vueResourceInterceptor = (request, next) => { - next(response => { - const requestId = response.headers['x-request-id']; - const requestUrl = response.url; - - if (requestUrl !== peekUrl && requestId) { - callback(requestId, requestUrl); - } - }); - }; - - Vue.http.interceptors.push(vueResourceInterceptor); - - return axios.interceptors.response.use(response => { + const interceptor = response => { const requestId = response.headers['x-request-id']; - const requestUrl = response.config.url; + // Get the request URL from response.config for Axios, and response for + // Vue Resource. + const requestUrl = (response.config || response).url; + const cachedResponse = response.headers['x-gitlab-from-cache'] === 'true'; - if (requestUrl !== peekUrl && requestId) { + if (requestUrl !== peekUrl && requestId && !cachedResponse) { callback(requestId, requestUrl); } return response; - }); + }; + + vueResourceInterceptor = (request, next) => next(interceptor); + + Vue.http.interceptors.push(vueResourceInterceptor); + + return axios.interceptors.response.use(interceptor); } static removeInterceptor(interceptor) { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index d7effb27bff..e99d949801f 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -1,60 +1,72 @@ <script> - import tooltip from '../../../vue_shared/directives/tooltip'; - import icon from '../../../vue_shared/components/icon.vue'; - import { dasherize } from '../../../lib/utils/text_utility'; - /** - * Renders either a cancel, retry or play icon pointing to the given path. - * TODO: Remove UJS from here and use an async request instead. - */ - export default { - components: { - icon, - }, +import $ from 'jquery'; +import tooltip from '../../../vue_shared/directives/tooltip'; +import Icon from '../../../vue_shared/components/icon.vue'; +import { dasherize } from '../../../lib/utils/text_utility'; +import eventHub from '../../event_hub'; +/** + * Renders either a cancel, retry or play icon pointing to the given path. + */ +export default { + components: { + Icon, + }, - directives: { - tooltip, - }, + directives: { + tooltip, + }, - props: { - tooltipText: { - type: String, - required: true, - }, + props: { + tooltipText: { + type: String, + required: true, + }, - link: { - type: String, - required: true, - }, + link: { + type: String, + required: true, + }, - actionMethod: { - type: String, - required: true, - }, + actionIcon: { + type: String, + required: true, + }, - actionIcon: { - type: String, - required: true, - }, + buttonDisabled: { + type: String, + required: false, + default: null, + }, + }, + computed: { + cssClass() { + const actionIconDash = dasherize(this.actionIcon); + return `${actionIconDash} js-icon-${actionIconDash}`; + }, + isDisabled() { + return this.buttonDisabled === this.link; }, + }, - computed: { - cssClass() { - const actionIconDash = dasherize(this.actionIcon); - return `${actionIconDash} js-icon-${actionIconDash}`; - }, + methods: { + onClickAction() { + $(this.$el).tooltip('hide'); + eventHub.$emit('graphAction', this.link); }, - }; + }, +}; </script> <template> - <a + <button + type="button" + @click="onClickAction" v-tooltip - :data-method="actionMethod" :title="tooltipText" - :href="link" - class="ci-action-icon-container ci-action-icon-wrapper" + class="btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper" :class="cssClass" data-container="body" + :disabled="isDisabled" > <icon :name="actionIcon" /> - </a> + </button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index ab84711d4a2..ac9ce7e47d6 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,54 +1,59 @@ <script> - import loadingIcon from '~/vue_shared/components/loading_icon.vue'; - import stageColumnComponent from './stage_column_component.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import StageColumnComponent from './stage_column_component.vue'; - export default { - components: { - stageColumnComponent, - loadingIcon, - }, +export default { + components: { + StageColumnComponent, + LoadingIcon, + }, - props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, + props: { + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, + actionDisabled: { + type: String, + required: false, + default: null, }, + }, - computed: { - graph() { - return this.pipeline.details && this.pipeline.details.stages; - }, + computed: { + graph() { + return this.pipeline.details && this.pipeline.details.stages; }, + }, - methods: { - capitalizeStageName(name) { - return name.charAt(0).toUpperCase() + name.slice(1); - }, + methods: { + capitalizeStageName(name) { + return name.charAt(0).toUpperCase() + name.slice(1); + }, - isFirstColumn(index) { - return index === 0; - }, + isFirstColumn(index) { + return index === 0; + }, - stageConnectorClass(index, stage) { - let className; + stageConnectorClass(index, stage) { + let className; - // If it's the first stage column and only has one job - if (index === 0 && stage.groups.length === 1) { - className = 'no-margin'; - } else if (index > 0) { - // If it is not the first column - className = 'left-margin'; - } + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } - return className; - }, + return className; }, - }; + }, +}; </script> <template> <div class="build-content middle-block js-pipeline-graph"> @@ -70,6 +75,7 @@ :key="stage.name" :stage-connector-class="stageConnectorClass(index, stage)" :is-first-column="isFirstColumn(index)" + :action-disabled="actionDisabled" /> </ul> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index 9b136573135..c6e5ae6df41 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -1,95 +1,102 @@ <script> - import actionComponent from './action_component.vue'; - import dropdownActionComponent from './dropdown_action_component.vue'; - import jobNameComponent from './job_name_component.vue'; - import tooltip from '../../../vue_shared/directives/tooltip'; - - /** - * Renders the badge for the pipeline graph and the job's dropdown. - * - * The following object should be provided as `job`: - * - * { - * "id": 4256, - * "name": "test", - * "status": { - * "icon": "icon_status_success", - * "text": "passed", - * "label": "passed", - * "group": "success", - * "details_path": "/root/ci-mock/builds/4256", - * "action": { - * "icon": "retry", - * "title": "Retry", - * "path": "/root/ci-mock/builds/4256/retry", - * "method": "post" - * } - * } - * } - */ - - export default { - components: { - actionComponent, - dropdownActionComponent, - jobNameComponent, +import ActionComponent from './action_component.vue'; +import DropdownActionComponent from './dropdown_action_component.vue'; +import JobNameComponent from './job_name_component.vue'; +import tooltip from '../../../vue_shared/directives/tooltip'; + +/** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "tooltip": "passed", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + +export default { + components: { + ActionComponent, + DropdownActionComponent, + JobNameComponent, + }, + + directives: { + tooltip, + }, + props: { + job: { + type: Object, + required: true, }, - directives: { - tooltip, + cssClassJobName: { + type: String, + required: false, + default: '', }, - props: { - job: { - type: Object, - required: true, - }, - - cssClassJobName: { - type: String, - required: false, - default: '', - }, - - isDropdown: { - type: Boolean, - required: false, - default: false, - }, + + isDropdown: { + type: Boolean, + required: false, + default: false, + }, + + actionDisabled: { + type: String, + required: false, + default: null, + }, + }, + + computed: { + status() { + return this.job && this.job.status ? this.job.status : {}; + }, + + tooltipText() { + const textBuilder = []; + + if (this.job.name) { + textBuilder.push(this.job.name); + } + + if (this.job.name && this.status.tooltip) { + textBuilder.push('-'); + } + + if (this.status.tooltip) { + textBuilder.push(`${this.job.status.tooltip}`); + } + + return textBuilder.join(' '); }, - computed: { - status() { - return this.job && this.job.status ? this.job.status : {}; - }, - - tooltipText() { - const textBuilder = []; - - if (this.job.name) { - textBuilder.push(this.job.name); - } - - if (this.job.name && this.status.label) { - textBuilder.push('-'); - } - - if (this.status.label) { - textBuilder.push(`${this.job.status.label}`); - } - - return textBuilder.join(' '); - }, - - /** - * Verifies if the provided job has an action path - * - * @return {Boolean} - */ - hasAction() { - return this.job.status && this.job.status.action && this.job.status.action.path; - }, + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; }, - }; + }, +}; </script> <template> <div class="ci-job-component"> @@ -100,6 +107,7 @@ :title="tooltipText" :class="cssClassJobName" data-container="body" + data-html="true" class="js-pipeline-graph-job-link" > @@ -115,6 +123,7 @@ class="js-job-component-tooltip" :title="tooltipText" :class="cssClassJobName" + data-html="true" data-container="body" > @@ -129,7 +138,7 @@ :tooltip-text="status.action.title" :link="status.action.path" :action-icon="status.action.icon" - :action-method="status.action.method" + :button-disabled="actionDisabled" /> <dropdown-action-component diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 7adcf4017b8..f6e6569e15b 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,50 +1,55 @@ <script> - import jobComponent from './job_component.vue'; - import dropdownJobComponent from './dropdown_job_component.vue'; +import JobComponent from './job_component.vue'; +import DropdownJobComponent from './dropdown_job_component.vue'; - export default { - components: { - jobComponent, - dropdownJobComponent, +export default { + components: { + JobComponent, + DropdownJobComponent, + }, + props: { + title: { + type: String, + required: true, }, - props: { - title: { - type: String, - required: true, - }, - jobs: { - type: Array, - required: true, - }, + jobs: { + type: Array, + required: true, + }, - isFirstColumn: { - type: Boolean, - required: false, - default: false, - }, + isFirstColumn: { + type: Boolean, + required: false, + default: false, + }, - stageConnectorClass: { - type: String, - required: false, - default: '', - }, + stageConnectorClass: { + type: String, + required: false, + default: '', + }, + actionDisabled: { + type: String, + required: false, + default: null, }, + }, - methods: { - firstJob(list) { - return list[0]; - }, + methods: { + firstJob(list) { + return list[0]; + }, - jobId(job) { - return `ci-badge-${job.name}`; - }, + jobId(job) { + return `ci-badge-${job.name}`; + }, - buildConnnectorClass(index) { - return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; - }, + buildConnnectorClass(index) { + return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; }, - }; + }, +}; </script> <template> <li @@ -69,6 +74,7 @@ v-if="job.size === 1" :job="job" css-class-job-name="build-content" + :action-disabled="actionDisabled" /> <dropdown-job-component diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index e0a7284124d..497a09cec65 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -7,10 +7,7 @@ import TablePagination from '../../vue_shared/components/table_pagination.vue'; import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; import NavigationControls from './nav_controls.vue'; - import { - getParameterByName, - parseQueryStringIntoObject, - } from '../../lib/utils/common_utils'; + import { getParameterByName } from '../../lib/utils/common_utils'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; export default { @@ -19,10 +16,7 @@ NavigationTabs, NavigationControls, }, - mixins: [ - pipelinesMixin, - CIPaginationMixin, - ], + mixins: [pipelinesMixin, CIPaginationMixin], props: { store: { type: Object, @@ -147,25 +141,26 @@ */ shouldRenderTabs() { const { stateMap } = this.$options; - return this.hasMadeRequest && - [ - stateMap.loading, - stateMap.tableList, - stateMap.error, - stateMap.emptyTab, - ].includes(this.stateToRender); + return ( + this.hasMadeRequest && + [stateMap.loading, stateMap.tableList, stateMap.error, stateMap.emptyTab].includes( + this.stateToRender, + ) + ); }, shouldRenderButtons() { - return (this.newPipelinePath || - this.resetCachePath || - this.ciLintPath) && this.shouldRenderTabs; + return ( + (this.newPipelinePath || this.resetCachePath || this.ciLintPath) && this.shouldRenderTabs + ); }, shouldRenderPagination() { - return !this.isLoading && + return ( + !this.isLoading && this.state.pipelines.length && - this.state.pageInfo.total > this.state.pageInfo.perPage; + this.state.pageInfo.total > this.state.pageInfo.perPage + ); }, emptyTabMessage() { @@ -229,15 +224,13 @@ }, methods: { successCallback(resp) { - return resp.json().then((response) => { - // Because we are polling & the user is interacting verify if the response received - // matches the last request made - if (_.isEqual(parseQueryStringIntoObject(resp.url.split('?')[1]), this.requestData)) { - this.store.storeCount(response.count); - this.store.storePagination(resp.headers); - this.setCommonData(response.pipelines); - } - }); + // Because we are polling & the user is interacting verify if the response received + // matches the last request made + if (_.isEqual(resp.config.params, this.requestData)) { + this.store.storeCount(resp.data.count); + this.store.storePagination(resp.headers); + this.setCommonData(resp.data.pipelines); + } }, /** * Handles URL and query parameter changes. @@ -251,8 +244,9 @@ this.updateInternalState(parameters); // fetch new data - return this.service.getPipelines(this.requestData) - .then((response) => { + return this.service + .getPipelines(this.requestData) + .then(response => { this.isLoading = false; this.successCallback(response); @@ -271,13 +265,11 @@ handleResetRunnersCache(endpoint) { this.isResetCacheButtonLoading = true; - this.service.postAction(endpoint) + this.service + .postAction(endpoint) .then(() => { this.isResetCacheButtonLoading = false; - createFlash( - s__('Pipelines|Project cache successfully reset.'), - 'notice', - ); + createFlash(s__('Pipelines|Project cache successfully reset.'), 'notice'); }) .catch(() => { this.isResetCacheButtonLoading = false; diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 8bc7a1f20b2..32cf3dba3c3 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -1,5 +1,4 @@ <script> - import $ from 'jquery'; /** * Renders each stage of the pipeline mini graph. @@ -14,15 +13,18 @@ * 4. Commit widget */ + import $ from 'jquery'; import Flash from '../../flash'; - import icon from '../../vue_shared/components/icon.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import axios from '../../lib/utils/axios_utils'; + import eventHub from '../event_hub'; + import Icon from '../../vue_shared/components/icon.vue'; + import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; export default { components: { - loadingIcon, - icon, + LoadingIcon, + Icon, }, directives: { @@ -82,15 +84,15 @@ methods: { onClickStage() { if (!this.isDropdownOpen()) { + eventHub.$emit('clickedDropdown'); this.isLoading = true; this.fetchJobs(); } }, fetchJobs() { - this.$http.get(this.stage.dropdown_path) - .then(response => response.json()) - .then((data) => { + axios.get(this.stage.dropdown_path) + .then(({ data }) => { this.dropdownContent = data.html; this.isLoading = false; }) @@ -98,8 +100,7 @@ this.closeDropdown(); this.isLoading = false; - const flash = new Flash('Something went wrong on our end.'); - return flash; + Flash('Something went wrong on our end.'); }); }, diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js new file mode 100644 index 00000000000..b384c7500e7 --- /dev/null +++ b/app/assets/javascripts/pipelines/constants.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const CANCEL_REQUEST = 'CANCEL_REQUEST'; diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 522a4277bd7..6d87f75ae8e 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -7,6 +7,7 @@ import SvgBlankState from '../components/blank_state.vue'; import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; import PipelinesTableComponent from '../components/pipelines_table.vue'; import eventHub from '../event_hub'; +import { CANCEL_REQUEST } from '../constants'; export default { components: { @@ -52,34 +53,58 @@ export default { }); eventHub.$on('postAction', this.postAction); + eventHub.$on('clickedDropdown', this.updateTable); }, beforeDestroy() { eventHub.$off('postAction', this.postAction); + eventHub.$off('clickedDropdown', this.updateTable); }, destroyed() { this.poll.stop(); }, methods: { + updateTable() { + // Cancel ongoing request + if (this.isMakingRequest) { + this.service.cancelationSource.cancel(CANCEL_REQUEST); + } + // Stop polling + this.poll.stop(); + // Update the table + return this.getPipelines() + .then(() => this.poll.restart()); + }, fetchPipelines() { if (!this.isMakingRequest) { this.isLoading = true; - this.service.getPipelines(this.requestData) - .then(response => this.successCallback(response)) - .catch(() => this.errorCallback()); + this.getPipelines(); } }, + getPipelines() { + return this.service.getPipelines(this.requestData) + .then(response => this.successCallback(response)) + .catch((error) => this.errorCallback(error)); + }, setCommonData(pipelines) { this.store.storePipelines(pipelines); this.isLoading = false; this.updateGraphDropdown = true; this.hasMadeRequest = true; + + // In case the previous polling request returned an error, we need to reset it + if (this.hasError) { + this.hasError = false; + } }, - errorCallback() { - this.hasError = true; - this.isLoading = false; - this.updateGraphDropdown = false; + errorCallback(error) { this.hasMadeRequest = true; + this.isLoading = false; + + if (error && error.message && error.message !== CANCEL_REQUEST) { + this.hasError = true; + this.updateGraphDropdown = false; + } }, setIsMakingRequest(isMakingRequest) { this.isMakingRequest = isMakingRequest; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 6b26708148c..900eb7855f4 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -25,13 +25,36 @@ export default () => { data() { return { mediator, + actionDisabled: null, }; }, + created() { + eventHub.$on('graphAction', this.postAction); + }, + beforeDestroy() { + eventHub.$off('graphAction', this.postAction); + }, + methods: { + postAction(action) { + this.actionDisabled = action; + + this.mediator.service.postAction(action) + .then(() => { + this.mediator.refreshPipeline(); + this.actionDisabled = null; + }) + .catch(() => { + this.actionDisabled = null; + Flash(__('An error occurred while making the request.')); + }); + }, + }, render(createElement) { return createElement('pipeline-graph', { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, + actionDisabled: this.actionDisabled, }, }); }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index 10f238fe73b..5633e54b28a 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -40,10 +40,8 @@ export default class pipelinesMediator { } successCallback(response) { - return response.json().then((data) => { - this.state.isLoading = false; - this.store.storePipeline(data); - }); + this.state.isLoading = false; + this.store.storePipeline(response.data); } errorCallback() { @@ -52,8 +50,11 @@ export default class pipelinesMediator { } refreshPipeline() { - this.service.getPipeline() + this.poll.stop(); + + return this.service.getPipeline() .then(response => this.successCallback(response)) - .catch(() => this.errorCallback()); + .catch(() => this.errorCallback()) + .finally(() => this.poll.restart()); } } diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js index 3e0c52c7726..a53a9cc8365 100644 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -1,19 +1,16 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -Vue.use(VueResource); +import axios from '../../lib/utils/axios_utils'; export default class PipelineService { constructor(endpoint) { - this.pipeline = Vue.resource(endpoint); + this.pipeline = endpoint; } getPipeline() { - return this.pipeline.get(); + return axios.get(this.pipeline); } - // eslint-disable-next-line + // eslint-disable-next-line class-methods-use-this postAction(endpoint) { - return Vue.http.post(`${endpoint}.json`); + return axios.post(`${endpoint}.json`); } } diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 47736fc5f42..59c8b9c58e5 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -1,35 +1,32 @@ -/* eslint-disable class-methods-use-this */ -import Vue from 'vue'; -import VueResource from 'vue-resource'; -import '../../vue_shared/vue_resource_interceptor'; - -Vue.use(VueResource); +import axios from '../../lib/utils/axios_utils'; export default class PipelinesService { - /** - * Commits and merge request endpoints need to be requested with `.json`. - * - * The url provided to request the pipelines in the new merge request - * page already has `.json`. - * - * @param {String} root - */ + * Commits and merge request endpoints need to be requested with `.json`. + * + * The url provided to request the pipelines in the new merge request + * page already has `.json`. + * + * @param {String} root + */ constructor(root) { - let endpoint; - if (root.indexOf('.json') === -1) { - endpoint = `${root}.json`; + this.endpoint = `${root}.json`; } else { - endpoint = root; + this.endpoint = root; } - - this.pipelines = Vue.resource(endpoint); } getPipelines(data = {}) { const { scope, page } = data; - return this.pipelines.get({ scope, page }); + const CancelToken = axios.CancelToken; + + this.cancelationSource = CancelToken.source(); + + return axios.get(this.endpoint, { + params: { scope, page }, + cancelToken: this.cancelationSource.token, + }); } /** @@ -38,7 +35,8 @@ export default class PipelinesService { * @param {String} endpoint * @return {Promise} */ + // eslint-disable-next-line class-methods-use-this postAction(endpoint) { - return Vue.http.post(`${endpoint}.json`); + return axios.post(`${endpoint}.json`); } } diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue new file mode 100644 index 00000000000..e5de3f69b01 --- /dev/null +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -0,0 +1,121 @@ +<script> +import _ from 'underscore'; +import axios from '~/lib/utils/axios_utils'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import { s__, sprintf } from '~/locale'; +import Flash from '~/flash'; + +export default { + components: { + GlModal, + }, + props: { + actionUrl: { + type: String, + required: true, + }, + rootUrl: { + type: String, + required: true, + }, + initialUsername: { + type: String, + required: true, + }, + }, + data() { + return { + isRequestPending: false, + username: this.initialUsername, + newUsername: this.initialUsername, + }; + }, + computed: { + path() { + return sprintf(s__('Profiles|Current path: %{path}'), { + path: `${this.rootUrl}${this.username}`, + }); + }, + modalText() { + return sprintf( + s__(`Profiles| +You are going to change the username %{currentUsernameBold} to %{newUsernameBold}. +Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group. +Please update your Git repository remotes as soon as possible.`), + { + currentUsernameBold: `<strong>${_.escape(this.username)}</strong>`, + newUsernameBold: `<strong>${_.escape(this.newUsername)}</strong>`, + currentUsername: _.escape(this.username), + newUsername: _.escape(this.newUsername), + }, + false, + ); + }, + }, + methods: { + onConfirm() { + this.isRequestPending = true; + const username = this.newUsername; + const putData = { + user: { + username, + }, + }; + + return axios + .put(this.actionUrl, putData) + .then(result => { + Flash(result.data.message, 'notice'); + this.username = username; + this.isRequestPending = false; + }) + .catch(error => { + Flash(error.response.data.message); + this.isRequestPending = false; + throw error; + }); + }, + }, + modalId: 'username-change-confirmation-modal', + inputId: 'username-change-input', + buttonText: s__('Profiles|Update username'), +}; +</script> +<template> + <div> + <div class="form-group"> + <label :for="$options.inputId">{{ s__('Profiles|Path') }}</label> + <div class="input-group"> + <div class="input-group-addon">{{ rootUrl }}</div> + <input + :id="$options.inputId" + class="form-control" + required="required" + v-model="newUsername" + :disabled="isRequestPending" + /> + </div> + <p class="help-block"> + {{ path }} + </p> + </div> + <button + :data-target="`#${$options.modalId}`" + class="btn btn-warning" + type="button" + data-toggle="modal" + :disabled="isRequestPending || newUsername === username" + > + {{ $options.buttonText }} + </button> + <gl-modal + :id="$options.modalId" + :header-title-text="s__('Profiles|Change username') + '?'" + footer-primary-button-variant="warning" + :footer-primary-button-text="$options.buttonText" + @submit="onConfirm" + > + <span v-html="modalText"></span> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js index 84049a1f0b7..59c13e1a042 100644 --- a/app/assets/javascripts/profile/account/index.js +++ b/app/assets/javascripts/profile/account/index.js @@ -1,10 +1,25 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import UpdateUsername from './components/update_username.vue'; import deleteAccountModal from './components/delete_account_modal.vue'; export default () => { Vue.use(Translate); + const updateUsernameElement = document.getElementById('update-username'); + // eslint-disable-next-line no-new + new Vue({ + el: updateUsernameElement, + components: { + UpdateUsername, + }, + render(createElement) { + return createElement('update-username', { + props: { ...updateUsernameElement.dataset }, + }); + }, + }); + const deleteAccountButton = document.getElementById('delete-account-button'); const deleteAccountModalEl = document.getElementById('delete-account-modal'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 7dd3e9858c6..2da022fde63 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -233,21 +233,21 @@ export default class SearchAutocomplete { const issueItems = [ { text: 'Issues assigned to me', - url: `${issuesPath}/?assignee_username=${userName}`, + url: `${issuesPath}/?assignee_id=${userId}`, }, { text: "Issues I've created", - url: `${issuesPath}/?author_username=${userName}`, + url: `${issuesPath}/?author_id=${userId}`, }, ]; const mergeRequestItems = [ { text: 'Merge requests assigned to me', - url: `${mrPath}/?assignee_username=${userName}`, + url: `${mrPath}/?assignee_id=${userId}`, }, { text: "Merge requests I've created", - url: `${mrPath}/?author_username=${userName}`, + url: `${mrPath}/?author_id=${userId}`, }, ]; diff --git a/app/assets/javascripts/shared/popover.js b/app/assets/javascripts/shared/popover.js new file mode 100644 index 00000000000..3fc03553bdd --- /dev/null +++ b/app/assets/javascripts/shared/popover.js @@ -0,0 +1,33 @@ +import $ from 'jquery'; +import _ from 'underscore'; + +export function togglePopover(show) { + const isAlreadyShown = this.hasClass('js-popover-show'); + if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) { + return false; + } + this.popover(show ? 'show' : 'hide'); + this.toggleClass('disable-animation js-popover-show', show); + + return true; +} + +export function mouseleave() { + if (!$('.popover:hover').length > 0) { + const $popover = $(this); + togglePopover.call($popover, false); + } +} + +export function mouseenter() { + const $popover = $(this); + + const showedPopover = togglePopover.call($popover, true); + if (showedPopover) { + $('.popover').on('mouseleave', mouseleave.bind($popover)); + } +} + +export function debouncedMouseleave(debounceTimeout = 300) { + return _.debounce(mouseleave, debounceTimeout); +} diff --git a/app/assets/javascripts/shortcuts_dashboard_navigation.js b/app/assets/javascripts/shortcuts_dashboard_navigation.js index 25f39e4fdb6..9f69f110d06 100644 --- a/app/assets/javascripts/shortcuts_dashboard_navigation.js +++ b/app/assets/javascripts/shortcuts_dashboard_navigation.js @@ -1,12 +1,15 @@ +import { visitUrl } from './lib/utils/url_utility'; + /** * Helper function that finds the href of the fiven selector and updates the location. * * @param {String} selector */ -export default (selector) => { - const link = document.querySelector(selector).getAttribute('href'); +export default function findAndFollowLink(selector) { + const element = document.querySelector(selector); + const link = element && element.getAttribute('href'); if (link) { - window.location = link; + visitUrl(link); } -}; +} diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js deleted file mode 100644 index 2d324c71379..00000000000 --- a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js +++ /dev/null @@ -1,17 +0,0 @@ -export default { - name: 'time-tracking-estimate-only-pane', - props: { - timeEstimateHumanReadable: { - type: String, - required: true, - }, - }, - template: ` - <div class="time-tracking-estimate-only-pane"> - <span class="bold"> - {{ s__('TimeTracking|Estimated:') }} - </span> - {{ timeEstimateHumanReadable }} - </div> - `, -}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue new file mode 100644 index 00000000000..08fce597e50 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue @@ -0,0 +1,20 @@ +<script> +export default { + name: 'TimeTrackingEstimateOnlyPane', + props: { + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="time-tracking-estimate-only-pane"> + <span class="bold"> + {{ s__('TimeTracking|Estimated:') }} + </span> + {{ timeEstimateHumanReadable }} + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index 19f74ad3c6d..825063d9ba6 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,7 +1,8 @@ +<script> import { sprintf, s__ } from '../../../locale'; export default { - name: 'time-tracking-help-state', + name: 'TimeTrackingHelpState', props: { rootPath: { type: String, @@ -27,26 +28,28 @@ export default { ); }, }, - template: ` - <div class="time-tracking-help-state"> - <div class="time-tracking-info"> - <h4> - {{ __('Track time with quick actions') }} - </h4> - <p> - {{ __('Quick actions can be used in the issues description and comment boxes.') }} - </p> - <p v-html="estimateText"> - </p> - <p v-html="spendText"> - </p> - <a - class="btn btn-default learn-more-button" - :href="href" - > - {{ __('Learn more') }} - </a> - </div> - </div> - `, }; +</script> + +<template> + <div class="time-tracking-help-state"> + <div class="time-tracking-info"> + <h4> + {{ __('Track time with quick actions') }} + </h4> + <p> + {{ __('Quick actions can be used in the issues description and comment boxes.') }} + </p> + <p v-html="estimateText"> + </p> + <p v-html="spendText"> + </p> + <a + class="btn btn-default learn-more-button" + :href="href" + > + {{ __('Learn more') }} + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 1c641c73ea3..71dca498b3d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,9 +1,9 @@ <script> -import timeTrackingHelpState from './help_state'; +import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import timeTrackingSpentOnlyPane from './spent_only_pane'; import timeTrackingNoTrackingPane from './no_tracking_pane'; -import timeTrackingEstimateOnlyPane from './estimate_only_pane'; +import TimeTrackingEstimateOnlyPane from './estimate_only_pane.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; import eventHub from '../../event_hub'; @@ -12,11 +12,11 @@ export default { name: 'IssuableTimeTracker', components: { TimeTrackingCollapsedState, - 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, + TimeTrackingEstimateOnlyPane, 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, TimeTrackingComparisonPane, - 'time-tracking-help-state': timeTrackingHelpState, + TimeTrackingHelpState, }, props: { time_estimate: { diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js new file mode 100644 index 00000000000..81ec483f2d9 --- /dev/null +++ b/app/assets/javascripts/snippet/snippet_embed.js @@ -0,0 +1,23 @@ +export default () => { + const { protocol, host, pathname } = location; + const shareBtn = document.querySelector('.js-share-btn'); + const embedBtn = document.querySelector('.js-embed-btn'); + const snippetUrlArea = document.querySelector('.js-snippet-url-area'); + const embedAction = document.querySelector('.js-embed-action'); + const url = `${protocol}//${host + pathname}`; + + shareBtn.addEventListener('click', () => { + shareBtn.classList.add('is-active'); + embedBtn.classList.remove('is-active'); + snippetUrlArea.value = url; + embedAction.innerText = 'Share'; + }); + + embedBtn.addEventListener('click', () => { + embedBtn.classList.add('is-active'); + shareBtn.classList.remove('is-active'); + const scriptTag = `<script src="${url}.js"></script>`; + snippetUrlArea.value = scriptTag; + embedAction.innerText = 'Embed'; + }); +}; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index f3b961eb109..520a0b3f424 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,6 +5,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; +import ModalStore from './boards/stores/modal_store'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; @@ -441,7 +442,7 @@ function UsersSelect(currentUser, els, options = {}) { return; } if ($el.closest('.add-issues-modal').length) { - gl.issueBoards.ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; + ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; } else if (handleClick) { e.preventDefault(); handleClick(user, isMarking); diff --git a/app/assets/javascripts/visibility_select.js b/app/assets/javascripts/visibility_select.js deleted file mode 100644 index 0c928d0d5f6..00000000000 --- a/app/assets/javascripts/visibility_select.js +++ /dev/null @@ -1,21 +0,0 @@ -export default class VisibilitySelect { - constructor(container) { - if (!container) throw new Error('VisibilitySelect requires a container element as argument 1'); - this.container = container; - this.helpBlock = this.container.querySelector('.help-block'); - this.select = this.container.querySelector('select'); - } - - init() { - if (this.select) { - this.updateHelpText(); - this.select.addEventListener('change', this.updateHelpText.bind(this)); - } else { - this.helpBlock.textContent = this.container.querySelector('.js-locked').dataset.helpBlock; - } - } - - updateHelpText() { - this.helpBlock.textContent = this.select.querySelector('option:checked').dataset.description; - } -} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue index 95c8b0a4c55..f012f9c6772 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue @@ -146,8 +146,8 @@ export default { </p> <p v-if="shouldShowMemoryGraph" - class="usage-info js-usage-info"> - {{ memoryChangeMessage }} + class="usage-info js-usage-info" + v-html="memoryChangeMessage"> </p> <p v-if="shouldShowLoadFailure" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 54a98abf860..48dff8c4916 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -1,56 +1,61 @@ <script> - /* eslint-disable vue/require-default-prop */ - import pipelineStage from '~/pipelines/components/stage.vue'; - import ciIcon from '~/vue_shared/components/ci_icon.vue'; - import icon from '~/vue_shared/components/icon.vue'; +/* eslint-disable vue/require-default-prop */ +import PipelineStage from '~/pipelines/components/stage.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; - export default { - name: 'MRWidgetPipeline', - components: { - pipelineStage, - ciIcon, - icon, +export default { + name: 'MRWidgetPipeline', + components: { + PipelineStage, + CiIcon, + Icon, + }, + props: { + pipeline: { + type: Object, + required: true, }, - props: { - pipeline: { - type: Object, - required: true, - }, - // This prop needs to be camelCase, html attributes are case insensive - // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case - hasCi: { - type: Boolean, - required: false, - }, - ciStatus: { - type: String, - required: false, - }, + // This prop needs to be camelCase, html attributes are case insensive + // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case + hasCi: { + type: Boolean, + required: false, }, - computed: { - hasPipeline() { - return this.pipeline && Object.keys(this.pipeline).length > 0; - }, - hasCIError() { - return this.hasCi && !this.ciStatus; - }, - status() { - return this.pipeline.details && - this.pipeline.details.status ? this.pipeline.details.status : {}; - }, - hasStages() { - return this.pipeline.details && - this.pipeline.details.stages && - this.pipeline.details.stages.length; - }, + ciStatus: { + type: String, + required: false, }, - }; + }, + computed: { + hasPipeline() { + return this.pipeline && Object.keys(this.pipeline).length > 0; + }, + hasCIError() { + return this.hasCi && !this.ciStatus; + }, + status() { + return this.pipeline.details && this.pipeline.details.status + ? this.pipeline.details.status + : {}; + }, + hasStages() { + return ( + this.pipeline.details && this.pipeline.details.stages && this.pipeline.details.stages.length + ); + }, + hasCommitInfo() { + return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; + }, + }, +}; </script> <template> <div v-if="hasPipeline || hasCIError" - class="mr-widget-heading"> + class="mr-widget-heading" + > <div class="ci-widget media"> <template v-if="hasCIError"> <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10"> @@ -77,13 +82,17 @@ #{{ pipeline.id }} </a> - {{ pipeline.details.status.label }} for + {{ pipeline.details.status.label }} - <a - :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link" - > - {{ pipeline.commit.short_id }}</a>. + <template v-if="hasCommitInfo"> + for + + <a + :href="pipeline.commit.commit_path" + class="commit-sha js-commit-link" + > + {{ pipeline.commit.short_id }}</a>. + </template> <span class="mr-widget-pipeline-graph"> <span diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js deleted file mode 100644 index 4d9a2ca530f..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js +++ /dev/null @@ -1,18 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetPipelineBlocked', - components: { - statusIcon, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="warning" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold"> - The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure - </span> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue new file mode 100644 index 00000000000..8d55477929f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -0,0 +1,25 @@ +<script> +import statusIcon from '../mr_widget_status_icon.vue'; + +export default { + name: 'PipelineFailed', + components: { + statusIcon, + }, +}; +</script> + +<template> + <div class="mr-widget-body media"> + <status-icon + status="warning" + :show-disabled-button="true" + /> + <div class="media-body space-children"> + <span class="bold"> + {{ s__(`mrWidget|The pipeline for this merge request failed. +Please retry the job or push a new commit to fix the failure`) }} + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 3c781ccddc8..0264625a526 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -1,3 +1,4 @@ +<script> import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import simplePoll from '~/lib/utils/simple_poll'; @@ -7,7 +8,10 @@ import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; export default { - name: 'MRWidgetReadyToMerge', + name: 'ReadyToMerge', + components: { + statusIcon, + }, props: { mr: { type: Object, required: true }, service: { type: Object, required: true }, @@ -26,9 +30,6 @@ export default { warningSvg, }; }, - components: { - statusIcon, - }, computed: { shouldShowMergeWhenPipelineSucceedsText() { return this.mr.isPipelineActive; @@ -217,136 +218,146 @@ export default { }); }, }, - template: ` - <div class="mr-widget-body media"> - <status-icon :status="iconClass" /> - <div class="media-body"> - <div class="mr-widget-body-controls media space-children"> - <span class="btn-group append-bottom-5"> - <button - @click="handleMergeButtonClick()" - :disabled="isMergeButtonDisabled" - :class="mergeButtonClass" - type="button" - class="qa-merge-button"> - <i - v-if="isMakingRequest" - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - {{mergeButtonText}} - </button> +}; +</script> + +<template> + <div class="mr-widget-body media"> + <status-icon :status="iconClass" /> + <div class="media-body"> + <div class="mr-widget-body-controls media space-children"> + <span class="btn-group append-bottom-5"> + <button + @click="handleMergeButtonClick()" + :disabled="isMergeButtonDisabled" + :class="mergeButtonClass" + type="button" + class="qa-merge-button"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" + ></i> + {{ mergeButtonText }} + </button> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-sm btn-info dropdown-toggle js-merge-moment" + data-toggle="dropdown" + aria-label="Select merge moment"> + <i + class="fa fa-chevron-down" + aria-hidden="true" + ></i> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu"> + <li> + <a + @click.prevent="handleMergeButtonClick(true)" + class="merge_when_pipeline_succeeds" + href="#"> + <span class="media"> + <span + v-html="successSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> + </span> + </a> + </li> + <li> + <a + @click.prevent="handleMergeButtonClick(false, true)" + class="accept-merge-request" + href="#"> + <span class="media"> + <span + v-html="warningSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="media-body merge-opt-title">Merge immediately</span> + </span> + </a> + </li> + </ul> + </span> + <div class="media-body-wrap space-children"> + <template v-if="shouldShowMergeControls()"> + <label v-if="mr.canRemoveSourceBranch"> + <input + id="remove-source-branch-input" + v-model="removeSourceBranch" + class="js-remove-source-branch-checkbox" + :disabled="isRemoveSourceBranchButtonDisabled" + type="checkbox"/> Remove source branch + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + :mr="mr" + :is-merge-button-disabled="isMergeButtonDisabled" /> + + <span + v-if="mr.ffOnlyEnabled" + class="js-fast-forward-message"> + Fast-forward merge without a merge commit + </span> <button - v-if="shouldShowMergeOptionsDropdown" + v-else + @click="toggleCommitMessageEditor" :disabled="isMergeButtonDisabled" - type="button" - class="btn btn-sm btn-info dropdown-toggle js-merge-moment" - data-toggle="dropdown" - aria-label="Select merge moment"> - <i - class="fa fa-chevron-down" - aria-hidden="true" /> + class="js-modify-commit-message-button btn btn-default btn-xs" + type="button"> + Modify commit message </button> - <ul - v-if="shouldShowMergeOptionsDropdown" - class="dropdown-menu dropdown-menu-right" - role="menu"> - <li> - <a - @click.prevent="handleMergeButtonClick(true)" - class="merge_when_pipeline_succeeds" - href="#"> - <span class="media"> - <span - v-html="successSvg" - class="merge-opt-icon" - aria-hidden="true"></span> - <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> - </span> - </a> - </li> - <li> - <a - @click.prevent="handleMergeButtonClick(false, true)" - class="accept-merge-request" - href="#"> - <span class="media"> - <span - v-html="warningSvg" - class="merge-opt-icon" - aria-hidden="true"></span> - <span class="media-body merge-opt-title">Merge immediately</span> - </span> - </a> - </li> - </ul> - </span> - <div class="media-body-wrap space-children"> - <template v-if="shouldShowMergeControls()"> - <label v-if="mr.canRemoveSourceBranch"> - <input - id="remove-source-branch-input" - v-model="removeSourceBranch" - class="js-remove-source-branch-checkbox" - :disabled="isRemoveSourceBranchButtonDisabled" - type="checkbox"/> Remove source branch - </label> - - <!-- Placeholder for EE extension of this component --> - <squash-before-merge - v-if="shouldShowSquashBeforeMerge" - :mr="mr" - :is-merge-button-disabled="isMergeButtonDisabled" /> - - <span - v-if="mr.ffOnlyEnabled" - class="js-fast-forward-message"> - Fast-forward merge without a merge commit - </span> - <button - v-else - @click="toggleCommitMessageEditor" - :disabled="isMergeButtonDisabled" - class="js-modify-commit-message-button btn btn-default btn-xs" - type="button"> - Modify commit message - </button> - </template> - <template v-else> - <span class="bold js-resolve-mr-widget-items-message"> - You can only merge once the items above are resolved - </span> - </template> - </div> + </template> + <template v-else> + <span class="bold js-resolve-mr-widget-items-message"> + You can only merge once the items above are resolved + </span> + </template> </div> - <div - v-if="showCommitMessageEditor" - class="prepend-top-default commit-message-editor"> - <div class="form-group clearfix"> - <label - class="control-label" - for="commit-message"> - Commit message - </label> - <div class="col-sm-10"> - <div class="commit-message-container"> - <div class="max-width-marker"></div> - <textarea - v-model="commitMessage" - class="form-control js-commit-message" - required="required" - rows="14" - name="Commit message"></textarea> - </div> - <p class="hint">Try to keep the first line under 52 characters and the others under 72</p> - <div class="hint"> - <a - @click.prevent="updateCommitMessage" - href="#">{{commitMessageLinkTitle}}</a> - </div> + </div> + <div + v-if="showCommitMessageEditor" + class="prepend-top-default commit-message-editor"> + <div class="form-group clearfix"> + <label + class="control-label" + for="commit-message"> + Commit message + </label> + <div class="col-sm-10"> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + id="commit-message" + v-model="commitMessage" + class="form-control js-commit-message" + required="required" + rows="14" + name="Commit message"></textarea> + </div> + <p class="hint"> + Try to keep the first line under 52 characters and the others under 72 + </p> + <div class="hint"> + <a + @click.prevent="updateCommitMessage" + href="#" + > + {{ commitMessageLinkTitle }} + </a> </div> </div> </div> </div> </div> - `, -}; + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 9ade6a91747..a1f7e696795 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -7,7 +7,10 @@ export default { statusIcon, }, props: { - mr: { type: Object, required: true }, + mr: { + type: Object, + required: true, + }, }, }; </script> @@ -20,13 +23,14 @@ export default { /> <div class="media-body space-children"> <span class="bold"> - There are unresolved discussions. Please resolve these discussions + {{ s__("mrWidget|There are unresolved discussions. Please resolve these discussions") }} </span> <a v-if="mr.createIssueToResolveDiscussionsPath" :href="mr.createIssueToResolveDiscussionsPath" - class="btn btn-default btn-xs js-create-issue"> - Create an issue to resolve them later + class="btn btn-default btn-xs js-create-issue" + > + {{ s__("mrWidget|Create an issue to resolve them later") }} </a> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index ed15fc6ab0f..3b5c973e4a0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -27,11 +27,11 @@ export { default as ConflictsState } from './components/states/mr_widget_conflic export { default as NothingToMergeState } from './components/states/nothing_to_merge.vue'; export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue'; export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue'; -export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; +export { default as ReadyToMergeState } from './components/states/ready_to_merge.vue'; export { default as ShaMismatchState } from './components/states/sha_mismatch.vue'; export { default as UnresolvedDiscussionsState } from './components/states/unresolved_discussions.vue'; export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue'; -export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; +export { default as PipelineFailedState } from './components/states/pipeline_failed.vue'; export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; export { default as RebaseState } from './components/states/mr_widget_rebase.vue'; export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed.vue'; diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 97789636787..b8875d04488 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -175,7 +175,7 @@ </a> </span> <span v-else> - Cant find HEAD commit for this branch + Can't find HEAD commit for this branch </span> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue new file mode 100644 index 00000000000..4155e1bab9c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -0,0 +1,58 @@ +<script> +import { viewerInformationForPath } from './lib/viewer_utils'; +import MarkdownViewer from './viewers/markdown_viewer.vue'; +import ImageViewer from './viewers/image_viewer.vue'; +import DownloadViewer from './viewers/download_viewer.vue'; + +export default { + props: { + content: { + type: String, + default: '', + }, + path: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: false, + default: 0, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + viewer() { + if (!this.path) return null; + + const previewInfo = viewerInformationForPath(this.path); + if (!previewInfo) return DownloadViewer; + + switch (previewInfo.id) { + case 'markdown': + return MarkdownViewer; + case 'image': + return ImageViewer; + default: + return DownloadViewer; + } + }, + }, +}; +</script> + +<template> + <div class="preview-container"> + <component + :is="viewer" + :path="path" + :file-size="fileSize" + :project-path="projectPath" + :content="content" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js new file mode 100644 index 00000000000..f01a51da0b3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js @@ -0,0 +1,32 @@ +const viewers = { + image: { + id: 'image', + }, + markdown: { + id: 'markdown', + previewTitle: 'Preview Markdown', + }, +}; + +const fileNameViewers = {}; +const fileExtensionViewers = { + jpg: 'image', + jpeg: 'image', + gif: 'image', + png: 'image', + bmp: 'image', + ico: 'image', + md: 'markdown', + markdown: 'markdown', +}; + +export function viewerInformationForPath(path) { + if (!path) return null; + const name = path.split('/').pop(); + const viewerName = + fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || ''; + + return viewers[viewerName]; +} + +export default viewers; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue new file mode 100644 index 00000000000..395a71acccf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -0,0 +1,52 @@ +<script> +import Icon from '../../icon.vue'; +import { numberToHumanSize } from '../../../../lib/utils/number_utils'; + +export default { + components: { + Icon, + }, + props: { + path: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + fileSizeReadable() { + return numberToHumanSize(this.fileSize); + }, + fileName() { + return this.path.split('/').pop(); + }, + }, +}; +</script> + +<template> + <div class="file-container"> + <div class="file-content"> + <p class="prepend-top-10 file-info"> + {{ fileName }} ({{ fileSizeReadable }}) + </p> + <a + :href="path" + class="btn btn-default" + rel="nofollow" + download + target="_blank"> + <icon + name="download" + css-classes="pull-left append-right-8" + :size="16" + /> + {{ __('Download') }} + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue new file mode 100644 index 00000000000..a5999f909ca --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -0,0 +1,68 @@ +<script> +import { numberToHumanSize } from '../../../../lib/utils/number_utils'; + +export default { + props: { + path: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: false, + default: 0, + }, + }, + data() { + return { + width: 0, + height: 0, + isZoomable: false, + isZoomed: false, + }; + }, + computed: { + fileSizeReadable() { + return numberToHumanSize(this.fileSize); + }, + }, + methods: { + onImgLoad() { + const contentImg = this.$refs.contentImg; + this.isZoomable = + contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height; + + this.width = contentImg.naturalWidth; + this.height = contentImg.naturalHeight; + }, + onImgClick() { + if (this.isZoomable) this.isZoomed = !this.isZoomed; + }, + }, +}; +</script> + +<template> + <div class="file-container"> + <div class="file-content image_file"> + <img + ref="contentImg" + :class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }" + :src="path" + :alt="path" + @load="onImgLoad" + @click="onImgClick"/> + <p class="file-info prepend-top-10"> + <template v-if="fileSize>0"> + {{ fileSizeReadable }} + </template> + <template v-if="fileSize>0 && width && height"> + - + </template> + <template v-if="width && height"> + {{ width }} x {{ height }} + </template> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue new file mode 100644 index 00000000000..09e0094054d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -0,0 +1,90 @@ +<script> +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import $ from 'jquery'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + +const CancelToken = axios.CancelToken; +let axiosSource; + +export default { + components: { + SkeletonLoadingContainer, + }, + props: { + content: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + previewContent: null, + isLoading: false, + }; + }, + watch: { + content() { + this.previewContent = null; + }, + }, + created() { + axiosSource = CancelToken.source(); + this.fetchMarkdownPreview(); + }, + updated() { + this.fetchMarkdownPreview(); + }, + destroyed() { + if (this.isLoading) axiosSource.cancel('Cancelling Preview'); + }, + methods: { + fetchMarkdownPreview() { + if (this.content && this.previewContent === null) { + this.isLoading = true; + const postBody = { + text: this.content, + }; + const postOptions = { + cancelToken: axiosSource.token, + }; + + axios + .post( + `${gon.relative_url_root}/${this.projectPath}/preview_markdown`, + postBody, + postOptions, + ) + .then(({ data }) => { + this.previewContent = data.body; + this.isLoading = false; + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); + }) + .catch(() => { + this.previewContent = __('An error occurred while fetching markdown preview'); + this.isLoading = false; + }); + } + }, + }, +}; +</script> + +<template> + <div + ref="markdown-preview" + class="md md-previewer"> + <skeleton-loading-container v-if="isLoading" /> + <div + v-else + v-html="previewContent"> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue index 67c9181c7b1..f28e5e2715d 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -1,47 +1,42 @@ <script> - const buttonVariants = [ - 'danger', - 'primary', - 'success', - 'warning', - ]; +const buttonVariants = ['danger', 'primary', 'success', 'warning']; - export default { - name: 'GlModal', +export default { + name: 'GlModal', - props: { - id: { - type: String, - required: false, - default: null, - }, - headerTitleText: { - type: String, - required: false, - default: '', - }, - footerPrimaryButtonVariant: { - type: String, - required: false, - default: 'primary', - validator: value => buttonVariants.indexOf(value) !== -1, - }, - footerPrimaryButtonText: { - type: String, - required: false, - default: '', - }, + props: { + id: { + type: String, + required: false, + default: null, }, + headerTitleText: { + type: String, + required: false, + default: '', + }, + footerPrimaryButtonVariant: { + type: String, + required: false, + default: 'primary', + validator: value => buttonVariants.includes(value), + }, + footerPrimaryButtonText: { + type: String, + required: false, + default: '', + }, + }, - methods: { - emitCancel(event) { - this.$emit('cancel', event); - }, - emitSubmit(event) { - this.$emit('submit', event); - }, + methods: { + emitCancel(event) { + this.$emit('cancel', event); + }, + emitSubmit(event) { + this.$emit('submit', event); }, - }; + }, +}; </script> <template> @@ -60,7 +55,7 @@ <slot name="header"> <button type="button" - class="close" + class="close js-modal-close-action" data-dismiss="modal" :aria-label="s__('Modal|Close')" @click="emitCancel($event)" @@ -83,7 +78,7 @@ <slot name="footer"> <button type="button" - class="btn" + class="btn js-modal-cancel-action" data-dismiss="modal" @click="emitCancel($event)" > @@ -91,7 +86,7 @@ </button> <button type="button" - class="btn" + class="btn js-modal-primary-action" :class="`btn-${footerPrimaryButtonVariant}`" data-dismiss="modal" @click="emitSubmit($event)" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index d91fe3cf0c5..db453c30576 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -27,20 +27,22 @@ $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab); }, methods: { - isMarkdownForm(form) { - return form && !form.find('.js-vue-markdown-field').length; + isValid(form) { + return !form || + form.find('.js-vue-markdown-field').length || + $(this.$el).closest('form') === form[0]; }, previewMarkdownTab(event, form) { if (event.target.blur) event.target.blur(); - if (this.isMarkdownForm(form)) return; + if (!this.isValid(form)) return; this.$emit('preview-markdown'); }, writeMarkdownTab(event, form) { if (event.target.blur) event.target.blur(); - if (this.isMarkdownForm(form)) return; + if (!this.isValid(form)) return; this.$emit('write-markdown'); }, diff --git a/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue index b06493e6c66..16304e4815d 100644 --- a/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue +++ b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue @@ -9,7 +9,7 @@ lines: { type: Number, required: false, - default: 6, + default: 3, }, }, computed: { diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 0665622fe4a..f2950308019 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -37,7 +37,11 @@ /* * Code highlight */ -@import "highlight/**/*"; +@import "highlight/dark"; +@import "highlight/monokai"; +@import "highlight/solarized_dark"; +@import "highlight/solarized_light"; +@import "highlight/white"; /* * Styles for JS behaviors. diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 728f9a27aca..14cd32da9eb 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -187,12 +187,9 @@ a { animation: fadeInFull $fade-in-duration 1; } - .animation-container { - background: $repo-editor-grey; height: 40px; overflow: hidden; - position: relative; &.animation-container-small { height: 12px; @@ -205,60 +202,43 @@ a { } } - &::before { - animation-duration: 1s; - animation-fill-mode: forwards; - animation-iteration-count: infinite; - animation-name: blockTextShine; - animation-timing-function: linear; - background-image: $repo-editor-linear-gradient; - background-repeat: no-repeat; - background-size: 800px 45px; - content: ' '; - display: block; - height: 100%; + [class^="skeleton-line-"] { position: relative; - } - - div { - background: $white-light; - height: 6px; - left: 0; - position: absolute; - right: 0; - } - - .skeleton-line-1 { - left: 0; - top: 8px; - } - - .skeleton-line-2 { - left: 150px; - top: 0; + background-color: $theme-gray-100; height: 10px; - } + overflow: hidden; - .skeleton-line-3 { - left: 0; - top: 23px; - } + &:not(:last-of-type) { + margin-bottom: 4px; + } - .skeleton-line-4 { - left: 0; - top: 38px; + &::after { + content: ' '; + display: block; + animation: blockTextShine 1s linear infinite forwards; + background-repeat: no-repeat; + background-size: cover; + background-image: linear-gradient( + to right, + $theme-gray-100 0%, + $theme-gray-50 20%, + $theme-gray-100 40%, + $theme-gray-100 100% + ); + height: 10px; + } } +} - .skeleton-line-5 { - left: 200px; - top: 28px; - height: 10px; - } +$skeleton-line-widths: ( + 156px, + 235px, + 200px, +); - .skeleton-line-6 { - top: 14px; - left: 230px; - height: 10px; +@for $count from 1 through length($skeleton-line-widths) { + .skeleton-line-#{$count} { + width: nth($skeleton-line-widths, $count); } } diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss index 6433b0c7855..02f3896d591 100644 --- a/app/assets/stylesheets/framework/banner.scss +++ b/app/assets/stylesheets/framework/banner.scss @@ -1,7 +1,7 @@ .banner-callout { display: flex; position: relative; - flex-wrap: wrap; + align-items: start; .banner-close { position: absolute; @@ -16,10 +16,25 @@ } .banner-graphic { - margin: 20px auto; + margin: 0 $gl-padding $gl-padding 0; } &.banner-non-empty-state { border-bottom: 1px solid $border-color; } + + @media (max-width: $screen-xs-max) { + justify-content: center; + flex-direction: column; + align-items: center; + + .banner-title, + .banner-buttons { + text-align: center; + } + + .banner-graphic { + margin-left: $gl-padding; + } + } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 6b89387ab5f..f4f5926e198 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -422,25 +422,43 @@ } } -.btn-link.btn-secondary-hover-link { - color: $gl-text-color-secondary; +.btn-link { + padding: 0; + background-color: transparent; + color: $blue-600; + font-weight: normal; + border-radius: 0; + border-color: transparent; &:hover, &:active, &:focus { - color: $gl-link-color; - text-decoration: none; + color: $blue-800; + text-decoration: underline; + background-color: transparent; + border-color: transparent; } -} -.btn-link.btn-primary-hover-link { - color: inherit; + &.btn-secondary-hover-link { + color: $gl-text-color-secondary; - &:hover, - &:active, - &:focus { - color: $gl-link-color; - text-decoration: none; + &:hover, + &:active, + &:focus { + color: $gl-link-color; + text-decoration: none; + } + } + + &.btn-primary-hover-link { + color: inherit; + + &:hover, + &:active, + &:focus { + color: $gl-link-color; + text-decoration: none; + } } } @@ -485,3 +503,7 @@ fieldset[disabled] .btn, @extend %disabled; } } + +.btn-no-padding { + padding: 0; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index cc74cb72795..cc5fac6816d 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -481,7 +481,8 @@ .dropdown-menu-selectable { li { - a { + a, + button { padding: 8px 40px; position: relative; diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index df1cafc9f8e..62a0fba3da3 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -20,7 +20,7 @@ width: 100%; } - $image-widths: 80 250 306 394 430; + $image-widths: 80 130 250 306 394 430; @each $width in $image-widths { &.svg-#{$width} { img, diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 7e829826eba..f1a8a46dda4 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -24,6 +24,10 @@ color: $list-text-disabled-color; } + &:not(.ui-sort-disabled):hover { + background: $row-hover; + } + &.unstyled { &:hover { background: none; @@ -34,14 +38,15 @@ background-color: $list-warning-row-bg; border-color: $list-warning-row-border; color: $list-warning-row-color; - } - &.smoke { background-color: $gray-light; } + &:hover { + background: $list-warning-row-bg; + } - &:not(.ui-sort-disabled):hover { - background: $row-hover; } + &.smoke { background-color: $gray-light; } + &:last-child { border-bottom: 0; diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index 7829d722560..34fccf6f0a4 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -39,7 +39,7 @@ .table-section { white-space: nowrap; - $section-widths: 10 15 20 25 30 40 100; + $section-widths: 10 15 20 25 30 40 50 100; @each $width in $section-widths { &.section-#{$width} { flex: 0 0 #{$width + '%'}; diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index 30c15c231d5..606d4675f19 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -29,8 +29,10 @@ } .snippet-title { - font-size: 24px; + color: $gl-text-color; + font-size: 2em; font-weight: $gl-font-weight-bold; + min-height: $header-height; } .snippet-edited-ago { @@ -46,3 +48,26 @@ .snippet-scope-menu .btn-new { margin-top: 15px; } + +.snippet-embed-input { + height: 35px; +} + +.embed-snippet { + padding-right: 0; + padding-top: $gl-padding; + + .form-control { + cursor: auto; + width: 101%; + margin-left: -1px; + } + + .embed-toggle-list li button { + padding: 8px 40px; + } + + .embed-toggle { + height: 35px; + } +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 294c59f037f..9e1371648ed 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -289,6 +289,11 @@ body { &:last-child { margin-bottom: 0; } + + &.with-button { + line-height: 34px; + } + } .page-title-empty { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index a81904d5338..37223175199 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -714,20 +714,6 @@ $color-average-score: $orange-400; $color-low-score: $red-400; /* -Repo editor -*/ -$repo-editor-grey: #f6f7f9; -$repo-editor-grey-darker: #e9ebee; -$repo-editor-linear-gradient: linear-gradient( - to right, - $repo-editor-grey 0%, - $repo-editor-grey-darker, - 20%, - $repo-editor-grey 40%, - $repo-editor-grey 100% -); - -/* Performance Bar */ $perf-bar-text: #999; @@ -767,3 +753,8 @@ $border-color-settings: #e1e1e1; Modals */ $modal-body-height: 134px; + +/* +Prometheus +*/ +$prometheus-table-row-highlight-color: $theme-gray-100; diff --git a/app/assets/stylesheets/highlight/embedded.scss b/app/assets/stylesheets/highlight/embedded.scss new file mode 100644 index 00000000000..44c8a1d39ec --- /dev/null +++ b/app/assets/stylesheets/highlight/embedded.scss @@ -0,0 +1,3 @@ +.code { + @import "white_base"; +} diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index c3d8f0c61a2..355c8d223f7 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -1,292 +1,3 @@ -/* https://github.com/aahan/pygments-github-style */ - -/* -* White Syntax Colors -*/ -$white-code-color: $gl-text-color; -$white-highlight: #fafe3d; -$white-pre-hll-bg: #f8eec7; -$white-hll-bg: #f8f8f8; -$white-over-bg: #ded7fc; -$white-expanded-border: #e0e0e0; -$white-expanded-bg: #f7f7f7; -$white-c: #998; -$white-err: #a61717; -$white-err-bg: #e3d2d2; -$white-cm: #998; -$white-cp: #999; -$white-c1: #998; -$white-cs: #999; -$white-gd: $black; -$white-gd-bg: #fdd; -$white-gd-x: $black; -$white-gd-x-bg: #faa; -$white-gr: #a00; -$white-gh: #999; -$white-gi: $black; -$white-gi-bg: #dfd; -$white-gi-x: $black; -$white-gi-x-bg: #afa; -$white-go: #888; -$white-gp: #555; -$white-gu: #800080; -$white-gt: #a00; -$white-kt: #458; -$white-m: #099; -$white-s: #d14; -$white-n: #333; -$white-na: teal; -$white-nb: #0086b3; -$white-nc: #458; -$white-no: teal; -$white-ni: purple; -$white-ne: #900; -$white-nf: #900; -$white-nn: #555; -$white-nt: navy; -$white-nv: teal; -$white-w: #bbb; -$white-mf: #099; -$white-mh: #099; -$white-mi: #099; -$white-mo: #099; -$white-sb: #d14; -$white-sc: #d14; -$white-sd: #d14; -$white-s2: #d14; -$white-se: #d14; -$white-sh: #d14; -$white-si: #d14; -$white-sx: #d14; -$white-sr: #009926; -$white-s1: #d14; -$white-ss: #990073; -$white-bp: #999; -$white-vc: teal; -$white-vg: teal; -$white-vi: teal; -$white-il: #099; -$white-gc-color: #999; -$white-gc-bg: #eaf2f5; - - -@mixin matchLine { - color: $black-transparent; - background-color: $gray-light; -} - .code.white { - // Line numbers - .line-numbers, - .diff-line-num { - background-color: $gray-light; - } - - .diff-line-num, - .diff-line-num a { - color: $black-transparent; - } - - // Code itself - pre.code, - .diff-line-num { - border-color: $white-normal; - } - - &, - pre.code, - .line_holder .line_content { - background-color: $white-light; - color: $white-code-color; - } - - // Diff line - .line_holder { - - &.match .line_content { - @include matchLine; - } - - .diff-line-num { - &.old { - background-color: $line-number-old; - border-color: $line-removed-dark; - - a { - color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); - } - } - - &.new { - background-color: $line-number-new; - border-color: $line-added-dark; - - a { - color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); - } - } - - &.is-over, - &.hll:not(.empty-cell).is-over { - background-color: $white-over-bg; - border-color: darken($white-over-bg, 5%); - - a { - color: darken($white-over-bg, 15%); - } - } - - &.hll:not(.empty-cell) { - background-color: $line-number-select; - border-color: $line-select-yellow-dark; - } - } - - &:not(.diff-expanded) + .diff-expanded, - &.diff-expanded + .line_holder:not(.diff-expanded) { - > .diff-line-num, - > .line_content { - border-top: 1px solid $white-expanded-border; - } - } - - &.diff-expanded { - > .diff-line-num, - > .line_content { - background: $white-expanded-bg; - border-color: $white-expanded-bg; - } - } - - .line_content { - &.old { - background-color: $line-removed; - - &::before { - color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); - } - - span.idiff { - background-color: $line-removed-dark; - } - } - - &.new { - background-color: $line-added; - - &::before { - color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); - } - - span.idiff { - background-color: $line-added-dark; - } - } - - &.match { - @include matchLine; - } - - &.hll:not(.empty-cell) { - background-color: $line-select-yellow; - } - } - } - - // highlight line via anchor - pre .hll { - background-color: $white-pre-hll-bg !important; - } - - // Search result highlight - span.highlight_word { - background-color: $white-highlight !important; - } - - // Links to URLs, emails, or dependencies - .line a { - color: $white-nb; - } - - .hll { background-color: $white-hll-bg; } - .c { color: $white-c; font-style: italic; } - .err { color: $white-err; background-color: $white-err-bg; } - .k { font-weight: $gl-font-weight-bold; } - .o { font-weight: $gl-font-weight-bold; } - .cm { color: $white-cm; font-style: italic; } - .cp { color: $white-cp; font-weight: $gl-font-weight-bold; } - .c1 { color: $white-c1; font-style: italic; } - .cs { color: $white-cs; font-weight: $gl-font-weight-bold; font-style: italic; } - - .gd { - color: $white-gd; - background-color: $white-gd-bg; - - .x { - color: $white-gd-x; - background-color: $white-gd-x-bg; - } - } - - .ge { font-style: italic; } - .gr { color: $white-gr; } - .gh { color: $white-gh; } - - .gi { - color: $white-gi; - background-color: $white-gi-bg; - - .x { - color: $white-gi-x; - background-color: $white-gi-x-bg; - } - } - - .go { color: $white-go; } - .gp { color: $white-gp; } - .gs { font-weight: $gl-font-weight-bold; } - .gu { color: $white-gu; font-weight: $gl-font-weight-bold; } - .gt { color: $white-gt; } - .kc { font-weight: $gl-font-weight-bold; } - .kd { font-weight: $gl-font-weight-bold; } - .kn { font-weight: $gl-font-weight-bold; } - .kp { font-weight: $gl-font-weight-bold; } - .kr { font-weight: $gl-font-weight-bold; } - .kt { color: $white-kt; font-weight: $gl-font-weight-bold; } - .m { color: $white-m; } - .s { color: $white-s; } - .n { color: $white-n; } - .na { color: $white-na; } - .nb { color: $white-nb; } - .nc { color: $white-nc; font-weight: $gl-font-weight-bold; } - .no { color: $white-no; } - .ni { color: $white-ni; } - .ne { color: $white-ne; font-weight: $gl-font-weight-bold; } - .nf { color: $white-nf; font-weight: $gl-font-weight-bold; } - .nn { color: $white-nn; } - .nt { color: $white-nt; } - .nv { color: $white-nv; } - .ow { font-weight: $gl-font-weight-bold; } - .w { color: $white-w; } - .mf { color: $white-mf; } - .mh { color: $white-mh; } - .mi { color: $white-mi; } - .mo { color: $white-mo; } - .sb { color: $white-sb; } - .sc { color: $white-sc; } - .sd { color: $white-sd; } - .s2 { color: $white-s2; } - .se { color: $white-se; } - .sh { color: $white-sh; } - .si { color: $white-si; } - .sx { color: $white-sx; } - .sr { color: $white-sr; } - .s1 { color: $white-s1; } - .ss { color: $white-ss; } - .bp { color: $white-bp; } - .vc { color: $white-vc; } - .vg { color: $white-vg; } - .vi { color: $white-vi; } - .il { color: $white-il; } - .gc { color: $white-gc-color; background-color: $white-gc-bg; } + @import "white_base"; } diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss new file mode 100644 index 00000000000..8cc5252648d --- /dev/null +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -0,0 +1,290 @@ +/* https://github.com/aahan/pygments-github-style */ + +/* +* White Syntax Colors +*/ +$white-code-color: $gl-text-color; +$white-highlight: #fafe3d; +$white-pre-hll-bg: #f8eec7; +$white-hll-bg: #f8f8f8; +$white-over-bg: #ded7fc; +$white-expanded-border: #e0e0e0; +$white-expanded-bg: #f7f7f7; +$white-c: #998; +$white-err: #a61717; +$white-err-bg: #e3d2d2; +$white-cm: #998; +$white-cp: #999; +$white-c1: #998; +$white-cs: #999; +$white-gd: $black; +$white-gd-bg: #fdd; +$white-gd-x: $black; +$white-gd-x-bg: #faa; +$white-gr: #a00; +$white-gh: #999; +$white-gi: $black; +$white-gi-bg: #dfd; +$white-gi-x: $black; +$white-gi-x-bg: #afa; +$white-go: #888; +$white-gp: #555; +$white-gu: #800080; +$white-gt: #a00; +$white-kt: #458; +$white-m: #099; +$white-s: #d14; +$white-n: #333; +$white-na: teal; +$white-nb: #0086b3; +$white-nc: #458; +$white-no: teal; +$white-ni: purple; +$white-ne: #900; +$white-nf: #900; +$white-nn: #555; +$white-nt: navy; +$white-nv: teal; +$white-w: #bbb; +$white-mf: #099; +$white-mh: #099; +$white-mi: #099; +$white-mo: #099; +$white-sb: #d14; +$white-sc: #d14; +$white-sd: #d14; +$white-s2: #d14; +$white-se: #d14; +$white-sh: #d14; +$white-si: #d14; +$white-sx: #d14; +$white-sr: #009926; +$white-s1: #d14; +$white-ss: #990073; +$white-bp: #999; +$white-vc: teal; +$white-vg: teal; +$white-vi: teal; +$white-il: #099; +$white-gc-color: #999; +$white-gc-bg: #eaf2f5; + + +@mixin matchLine { + color: $black-transparent; + background-color: $gray-light; +} + + // Line numbers +.line-numbers, +.diff-line-num { + background-color: $gray-light; +} + +.diff-line-num, +.diff-line-num a { + color: $black-transparent; +} + +// Code itself +pre.code, +.diff-line-num { + border-color: $white-normal; +} + +&, +pre.code, +.line_holder .line_content { + background-color: $white-light; + color: $white-code-color; +} + +// Diff line +.line_holder { + + &.match .line_content { + @include matchLine; + } + + .diff-line-num { + &.old { + background-color: $line-number-old; + border-color: $line-removed-dark; + + a { + color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); + } + } + + &.new { + background-color: $line-number-new; + border-color: $line-added-dark; + + a { + color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); + } + } + + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $white-over-bg; + border-color: darken($white-over-bg, 5%); + + a { + color: darken($white-over-bg, 15%); + } + } + + &.hll:not(.empty-cell) { + background-color: $line-number-select; + border-color: $line-select-yellow-dark; + } + } + + &:not(.diff-expanded) + .diff-expanded, + &.diff-expanded + .line_holder:not(.diff-expanded) { + > .diff-line-num, + > .line_content { + border-top: 1px solid $white-expanded-border; + } + } + + &.diff-expanded { + > .diff-line-num, + > .line_content { + background: $white-expanded-bg; + border-color: $white-expanded-bg; + } + } + + .line_content { + &.old { + background-color: $line-removed; + + &::before { + color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); + } + + span.idiff { + background-color: $line-removed-dark; + } + } + + &.new { + background-color: $line-added; + + &::before { + color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); + } + + span.idiff { + background-color: $line-added-dark; + } + } + + &.match { + @include matchLine; + } + + &.hll:not(.empty-cell) { + background-color: $line-select-yellow; + } + } +} + +// highlight line via anchor +pre .hll { + background-color: $white-pre-hll-bg !important; +} + + // Search result highlight +span.highlight_word { + background-color: $white-highlight !important; +} + + // Links to URLs, emails, or dependencies +.line a { + color: $white-nb; +} + +.hll { background-color: $white-hll-bg; } +.c { color: $white-c; font-style: italic; } +.err { color: $white-err; background-color: $white-err-bg; } +.k { font-weight: $gl-font-weight-bold; } +.o { font-weight: $gl-font-weight-bold; } +.cm { color: $white-cm; font-style: italic; } +.cp { color: $white-cp; font-weight: $gl-font-weight-bold; } +.c1 { color: $white-c1; font-style: italic; } +.cs { color: $white-cs; font-weight: $gl-font-weight-bold; font-style: italic; } + +.gd { + color: $white-gd; + background-color: $white-gd-bg; + + .x { + color: $white-gd-x; + background-color: $white-gd-x-bg; + } +} + +.ge { font-style: italic; } +.gr { color: $white-gr; } +.gh { color: $white-gh; } + +.gi { + color: $white-gi; + background-color: $white-gi-bg; + + .x { + color: $white-gi-x; + background-color: $white-gi-x-bg; + } +} + +.go { color: $white-go; } +.gp { color: $white-gp; } +.gs { font-weight: $gl-font-weight-bold; } +.gu { color: $white-gu; font-weight: $gl-font-weight-bold; } +.gt { color: $white-gt; } +.kc { font-weight: $gl-font-weight-bold; } +.kd { font-weight: $gl-font-weight-bold; } +.kn { font-weight: $gl-font-weight-bold; } +.kp { font-weight: $gl-font-weight-bold; } +.kr { font-weight: $gl-font-weight-bold; } +.kt { color: $white-kt; font-weight: $gl-font-weight-bold; } +.m { color: $white-m; } +.s { color: $white-s; } +.n { color: $white-n; } +.na { color: $white-na; } +.nb { color: $white-nb; } +.nc { color: $white-nc; font-weight: $gl-font-weight-bold; } +.no { color: $white-no; } +.ni { color: $white-ni; } +.ne { color: $white-ne; font-weight: $gl-font-weight-bold; } +.nf { color: $white-nf; font-weight: $gl-font-weight-bold; } +.nn { color: $white-nn; } +.nt { color: $white-nt; } +.nv { color: $white-nv; } +.ow { font-weight: $gl-font-weight-bold; } +.w { color: $white-w; } +.mf { color: $white-mf; } +.mh { color: $white-mh; } +.mi { color: $white-mi; } +.mo { color: $white-mo; } +.sb { color: $white-sb; } +.sc { color: $white-sc; } +.sd { color: $white-sd; } +.s2 { color: $white-s2; } +.se { color: $white-se; } +.sh { color: $white-sh; } +.si { color: $white-si; } +.sx { color: $white-sx; } +.sr { color: $white-sr; } +.s1 { color: $white-s1; } +.ss { color: $white-ss; } +.bp { color: $white-bp; } +.vc { color: $white-vc; } +.vg { color: $white-vg; } +.vi { color: $white-vi; } +.il { color: $white-il; } +.gc { color: $white-gc-color; background-color: $white-gc-bg; } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 98d460339cd..7a6352e45f1 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -391,7 +391,7 @@ } &:hover { - background-color: $row-hover; + background-color: $dropdown-item-hover-bg; } .icon-retry { diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index b487f6278c2..86cdda0359e 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -107,7 +107,6 @@ } } - .commits-compare-switch { float: left; margin-right: 9px; @@ -179,7 +178,7 @@ .commit-detail { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; flex-grow: 1; .merge-request-branches & { @@ -200,37 +199,63 @@ } .ci-status-link { - display: inline-block; - position: relative; - top: 2px; + display: inline-flex; } - .btn-clipboard, - .btn-transparent { - padding-left: 0; - padding-right: 0; + > .ci-status-link, + > .btn, + > .commit-sha-group { + margin-left: $gl-padding-8; } +} +.commit-sha-group { + display: inline-flex; + + .label, .btn { - &:not(:first-child) { - margin-left: $gl-padding; - } + padding: $gl-vert-padding $gl-btn-padding; + border: 1px $border-color solid; + font-size: $gl-font-size; + line-height: $line-height-base; + border-radius: 0; + display: flex; + align-items: center; + } + + .label-monospace { + @extend .monospace; + user-select: text; + color: $gl-text-color; + background-color: $gray-light; } - .commit-sha { - font-size: 14px; - font-weight: $gl-font-weight-bold; + .btn svg { + top: auto; + fill: $gl-text-color-secondary; } - .ci-status-icon { - position: relative; - top: 2px; + .fa-clipboard { + color: $gl-text-color-secondary; + } + + :first-child { + border-bottom-left-radius: $border-radius-default; + border-top-left-radius: $border-radius-default; + } + + :not(:first-child) { + border-left: 0; + } + + :last-child { + border-bottom-right-radius: $border-radius-default; + border-top-right-radius: $border-radius-default; } } .commit, .generic_commit_status { - a, button { color: $gl-text-color; @@ -303,10 +328,8 @@ } } - .gpg-status-box { padding: 2px 10px; - margin-right: $gl-padding; &:empty { display: none; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 7f037582ca0..11052be40a8 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -160,6 +160,11 @@ } } } + + .diff-loading-error-block { + padding: $gl-padding * 2 $gl-padding; + text-align: center; + } } .image { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 58700661142..3a300086fa3 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -273,21 +273,6 @@ line-height: 1.2; } - table { - border-collapse: collapse; - padding: 0; - margin: 0; - } - - td { - vertical-align: middle; - - + td { - padding-left: 5px; - vertical-align: top; - } - } - .deploy-meta-content { border-bottom: 1px solid $white-dark; @@ -323,6 +308,26 @@ } } +.prometheus-table { + border-collapse: collapse; + padding: 0; + margin: 0; + + td { + vertical-align: middle; + + + td { + padding-left: 5px; + vertical-align: top; + } + } + + .legend-metric-title { + font-size: 12px; + vertical-align: middle; + } +} + .prometheus-svg-container { position: relative; height: 0; @@ -330,8 +335,7 @@ padding: 0; padding-bottom: 100%; - .text-metric-usage, - .legend-metric-title { + .text-metric-usage { fill: $black; font-weight: $gl-font-weight-normal; font-size: 12px; @@ -374,10 +378,6 @@ } } - .text-metric-title { - font-size: 12px; - } - .y-label-text, .x-label-text { fill: $gray-darkest; @@ -414,3 +414,7 @@ } } } + +.prometheus-table-row-highlight { + background-color: $prometheus-table-row-highlight-color; +} diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index b2250a1ce2f..97303d02666 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -154,26 +154,10 @@ a { width: 100%; font-size: 18px; - margin-right: 0; - - &:hover { - border: 1px solid transparent; - } } - &.active { - border-bottom: 1px solid $border-color; - - a { - border: 0; - border-bottom: 2px solid $link-underline-blue; - margin-right: 0; - color: $black; - - &:hover { - border-bottom: 2px solid $link-underline-blue; - } - } + &.active > a { + cursor: default; } } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 4692d0fb873..66db4917178 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -762,3 +762,20 @@ max-width: 100%; } } + +// Hack alert: we've rewritten `btn` class in a way that +// we've broken it and it is not possible to use with `btn-link` +// which causes a blank button when it's disabled and hovering +// The css in here is the boostrap one +.btn-link-retry { + &[disabled] { + cursor: not-allowed; + box-shadow: none; + opacity: .65; + + &:hover { + color: $file-mode-changed; + text-decoration: none; + } + } +} diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index e5afa8fffcb..3af8d80daab 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -194,3 +194,38 @@ .issuable-row { background-color: $white-light; } + +.milestone-deprecation-message { + .popover { + padding: 0; + } + + .popover-content { + padding: 0; + } +} + +.milestone-popover-body { + padding: $gl-padding-8; + background-color: $gray-light; +} + +.milestone-popover-footer { + padding: $gl-padding-8 $gl-padding; + border-top: 1px solid $white-dark; +} + +.milestone-popover-instructions-list { + padding-left: 2em; + + > li { + padding-left: 1em; + } +} + +@media (max-width: $screen-xs-max) { + .milestone-banner-text, + .milestone-banner-link { + display: inline; + } +} diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss new file mode 100644 index 00000000000..fb42dee66d2 --- /dev/null +++ b/app/assets/stylesheets/pages/pages.scss @@ -0,0 +1,60 @@ +.pages-domain-list { + &-item { + position: relative; + display: flex; + align-items: center; + + .domain-status { + display: inline-flex; + left: $gl-padding; + position: absolute; + } + + .domain-name { + flex-grow: 1; + } + + } + + &.has-verification-status > li { + padding-left: 3 * $gl-padding; + } + +} + +.status-badge { + + display: inline-flex; + margin-bottom: $gl-padding-8; + + // Most of the following settings "stolen" from btn-sm + // Border radius is overwritten for both + .label, + .btn { + padding: $gl-padding-4 $gl-padding-8; + font-size: $gl-font-size; + line-height: $gl-btn-line-height; + border-radius: 0; + display: flex; + align-items: center; + } + + .btn svg { + top: auto; + } + + :first-child { + border-bottom-left-radius: $border-radius-default; + border-top-left-radius: $border-radius-default; + } + + :not(:first-child) { + border-left: 0; + } + + :last-child { + border-bottom-right-radius: $border-radius-default; + border-top-right-radius: $border-radius-default; + } + +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index ce2f1482456..855ebf7d86d 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -14,6 +14,11 @@ .commit-title { margin: 0; + white-space: normal; + + @media (max-width: $screen-sm-max) { + justify-content: flex-end; + } } .ci-table { @@ -344,7 +349,6 @@ svg { vertical-align: middle; - margin-right: 3px; } .stage-column { @@ -495,17 +499,12 @@ svg { fill: $gl-text-color-secondary; position: relative; - left: 5px; - top: 2px; - width: 18px; - height: 18px; + top: -1px; } &.play { svg { - width: #{$ci-action-icon-size - 8}; - height: #{$ci-action-icon-size - 8}; - left: 8px; + left: 2px; } } } diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index ac745019319..b199f9876d3 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -210,13 +210,8 @@ } .created-personal-access-token-container { - #created-personal-access-token { - width: 90%; - display: inline; - } - .btn-clipboard { - margin-left: 5px; + border: 1px solid $border-color; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 9a770d77685..d7d343b088a 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -935,11 +935,6 @@ pre.light-well { } } - .dropdown-menu-toggle { - width: 100%; - max-width: 300px; - } - .flash-container { padding: 0; } @@ -1143,3 +1138,11 @@ pre.light-well { white-space: pre-wrap; } } + +.project-badge { + opacity: 0.9; + + &:hover { + opacity: 1; + } +} diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 1f6f7138e1f..5f46e69a56d 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -308,14 +308,73 @@ height: 100%; } -.multi-file-editor-btn-group { - padding: $gl-bar-padding $gl-padding; - border-top: 1px solid $white-dark; +.preview-container { + height: 100%; + overflow: auto; + + .file-container { + background-color: $gray-darker; + display: flex; + height: 100%; + align-items: center; + justify-content: center; + + text-align: center; + + .file-content { + padding: $gl-padding; + max-width: 100%; + max-height: 100%; + + img { + max-width: 90%; + max-height: 90%; + } + + .isZoomable { + cursor: pointer; + cursor: zoom-in; + + &.isZoomed { + cursor: pointer; + cursor: zoom-out; + max-width: none; + max-height: none; + margin-right: $gl-padding; + } + } + } + + .file-info { + font-size: $label-font-size; + color: $diff-image-info-color; + } + } + + .md-previewer { + padding: $gl-padding; + } +} + +.ide-mode-tabs { border-bottom: 1px solid $white-dark; - background: $white-light; + + .nav-links { + border-bottom: 0; + + li a { + padding: $gl-padding-8 $gl-padding; + line-height: $gl-btn-line-height; + } + } +} + +.ide-btn-group { + padding: $gl-padding-4 $gl-vert-padding; } .ide-status-bar { + border-top: 1px solid $white-dark; padding: $gl-bar-padding $gl-padding; background: $white-light; display: flex; @@ -370,6 +429,7 @@ .projects-sidebar { display: flex; flex-direction: column; + height: 100%; .context-header { width: auto; @@ -379,8 +439,8 @@ .multi-file-commit-panel-inner { display: flex; - flex: 1; flex-direction: column; + height: 100%; } .multi-file-commit-panel-inner-scroll { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index a6ca8ed5016..c410049bc0b 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -284,3 +284,23 @@ .deprecated-service { cursor: default; } + +.personal-access-tokens-never-expires-label { + color: $note-disabled-comment-color; +} + +.created-deploy-token-container { + .deploy-token-field { + width: 90%; + display: inline; + } + + .btn-clipboard { + margin-left: 5px; + } + + .deploy-token-help-block { + display: block; + margin-bottom: 0; + } +} diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss new file mode 100644 index 00000000000..0d6b0735f70 --- /dev/null +++ b/app/assets/stylesheets/snippets.scss @@ -0,0 +1,156 @@ +@import "framework/variables"; + +.gitlab-embed-snippets { + @import "highlight/embedded"; + @import "framework/images"; + + $border-style: 1px solid $border-color; + + font-family: $regular_font; + font-size: $gl-font-size; + line-height: $code_line_height; + color: $gl-text-color; + margin: 20px; + font-weight: 200; + + .gl-snippet-icon { + display: inline-block; + background: url(asset_path('ext_snippet_icons/ext_snippet_icons.png')) no-repeat; + overflow: hidden; + text-align: left; + width: 16px; + height: 16px; + background-size: cover; + + &.gl-snippet-icon-doc_code { background-position: 0 0; } + &.gl-snippet-icon-doc_text { background-position: 0 -16px; } + &.gl-snippet-icon-download { background-position: 0 -32px; } + } + + .blob-viewer { + background-color: $white-light; + text-align: left; + } + + .file-content.code { + border: $border-style; + border-radius: 0 0 4px 4px; + display: flex; + box-shadow: none; + margin: 0; + padding: 0; + table-layout: fixed; + + .blob-content { + overflow-x: auto; + + pre { + padding: 10px; + border: 0; + border-radius: 0; + font-family: $monospace_font; + font-size: $code_font_size; + line-height: $code_line_height; + margin: 0; + overflow: auto; + overflow-y: hidden; + white-space: pre; + word-wrap: normal; + border-left: $border-style; + } + } + + .line-numbers { + padding: 10px; + text-align: right; + float: left; + + .diff-line-num { + font-family: $monospace_font; + display: block; + font-size: $code_font_size; + min-height: $code_line_height; + white-space: nowrap; + color: $black-transparent; + min-width: 30px; + } + + .diff-line-num:hover { + color: $almost-black; + cursor: pointer; + } + } + } + + .file-title-flex-parent { + display: flex; + align-items: center; + justify-content: space-between; + background-color: $gray-light; + border: $border-style; + border-bottom: 0; + padding: $gl-padding-top $gl-padding; + margin: 0; + border-radius: $border-radius-default $border-radius-default 0 0; + + .file-header-content { + .file-title-name { + font-weight: $gl-font-weight-bold; + } + + .gitlab-embedded-snippets-title { + text-decoration: none; + color: $gl-text-color; + + &:hover { + text-decoration: underline; + } + } + + .gitlab-logo { + display: inline-block; + padding-left: 5px; + text-decoration: none; + color: $gl-text-color-secondary; + + .logo-text { + background: image_url('ext_snippet_icons/logo.png') no-repeat left center; + background-size: 18px; + font-weight: $gl-font-weight-normal; + padding-left: 24px; + } + } + } + + img, + .gl-snippet-icon { + display: inline-block; + vertical-align: middle; + } + } + + .btn-group { + a.btn { + background-color: $white-light; + text-decoration: none; + padding: 7px 9px; + border: $border-style; + border-right: 0; + + &:hover { + background-color: $white-normal; + border-color: $border-white-normal; + text-decoration: none; + } + + &:first-child { + border-radius: 3px 0 0 3px; + } + + &:last-child { + border-radius: 0 3px 3px 0; + border-right: $border-style; + } + } + } +} diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 4dfb397e82c..8958eab0423 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -56,21 +56,18 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end def application_setting_params - import_sources = params[:application_setting][:import_sources] - if import_sources.nil? - params[:application_setting][:import_sources] = [] - else - import_sources.map! do |source| - source.to_str - end - end + params[:application_setting] ||= {} - enabled_oauth_sign_in_sources = params[:application_setting].delete(:enabled_oauth_sign_in_sources) + if params[:application_setting].key?(:enabled_oauth_sign_in_sources) + enabled_oauth_sign_in_sources = params[:application_setting].delete(:enabled_oauth_sign_in_sources) + enabled_oauth_sign_in_sources&.delete("") - params[:application_setting][:disabled_oauth_sign_in_sources] = - AuthHelper.button_based_providers.map(&:to_s) - - Array(enabled_oauth_sign_in_sources) + params[:application_setting][:disabled_oauth_sign_in_sources] = + AuthHelper.button_based_providers.map(&:to_s) - + Array(enabled_oauth_sign_in_sources) + end + params[:application_setting][:import_sources]&.delete("") params[:application_setting][:restricted_visibility_levels]&.delete("") params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file] diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 19dbee84c11..7d7ff217e5d 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -96,7 +96,8 @@ module Boards resource.as_json( only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position], labels: true, - sidebar_endpoints: true, + issue_endpoints: true, + include_full_project_path: board.group_board?, include: { project: { only: [:id, :path] }, assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 2753f83c3cf..2fdf346ef44 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -10,7 +10,7 @@ module AuthenticatesWithTwoFactor # This action comes from DeviseController, but because we call `sign_in` # manually, not skipping this action would cause a "You are already signed # in." error message to be shown upon successful login. - skip_before_action :require_no_authentication, only: [:create] + skip_before_action :require_no_authentication, only: [:create], raise: false end # Store the user's ID in the session for later retrieval and render the diff --git a/app/controllers/concerns/checks_collaboration.rb b/app/controllers/concerns/checks_collaboration.rb new file mode 100644 index 00000000000..81367663a06 --- /dev/null +++ b/app/controllers/concerns/checks_collaboration.rb @@ -0,0 +1,21 @@ +module ChecksCollaboration + def can_collaborate_with_project?(project, ref: nil) + return true if can?(current_user, :push_code, project) + + can_create_merge_request = + can?(current_user, :create_merge_request_in, project) && + current_user.already_forked?(project) + + can_create_merge_request || + user_access(project).can_push_to_branch?(ref) + end + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + # enabling this so we can easily cache the user access value as it might be + # used across multiple calls in the view + def user_access(project) + @user_access ||= {} + @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables +end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 839cac3687c..ad4e936a3d4 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -41,7 +41,7 @@ module NotesActions @note = Notes::CreateService.new(note_project, current_user, create_params).execute if @note.is_a?(Note) - Notes::RenderService.new(current_user).execute([@note], @project) + Notes::RenderService.new(current_user).execute([@note]) end respond_to do |format| @@ -56,7 +56,7 @@ module NotesActions @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) if @note.is_a?(Note) - Notes::RenderService.new(current_user).execute([@note], @project) + Notes::RenderService.new(current_user).execute([@note]) end respond_to do |format| diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index e7ef297879f..36e3d76ecfe 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -4,7 +4,7 @@ module RendersNotes preload_noteable_for_regular_notes(notes) preload_max_access_for_authors(notes, @project) preload_first_time_contribution_for_authors(noteable, notes) - Notes::RenderService.new(current_user).execute(notes, @project) + Notes::RenderService.new(current_user).execute(notes) notes end diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 9095cc7f783..120614739aa 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -17,6 +17,10 @@ module SnippetsActions end # rubocop:enable Gitlab/ModuleWithInstanceVariables + def js_request? + request.format.js? + end + private def convert_line_endings(content) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 280ed93faf8..68d328fa797 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,9 +2,17 @@ class DashboardController < Dashboard::ApplicationController include IssuesAction include MergeRequestsAction + FILTER_PARAMS = [ + :author_id, + :assignee_id, + :milestone_title, + :label_name + ].freeze + before_action :event_filter, only: :activity before_action :projects, only: [:issues, :merge_requests] before_action :set_show_full_reference, only: [:issues, :merge_requests] + before_action :check_filters_presence!, only: [:issues, :merge_requests] respond_to :html @@ -39,4 +47,15 @@ class DashboardController < Dashboard::ApplicationController def set_show_full_reference @show_full_reference = true end + + def check_filters_presence! + @no_filters_set = FILTER_PARAMS.none? { |k| params.key?(k) } + + return unless @no_filters_set + + respond_to do |format| + format.html + format.atom { head :bad_request } + end + end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index acf6aaf57f4..5903689dc62 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -12,7 +12,7 @@ class Groups::MilestonesController < Groups::ApplicationController @milestones = Kaminari.paginate_array(milestones).page(params[:page]) end format.json do - render json: milestones.map { |m| m.for_display.slice(:title, :name) } + render json: milestones.map { |m| m.for_display.slice(:id, :title, :name) } end end end diff --git a/app/controllers/groups/settings/badges_controller.rb b/app/controllers/groups/settings/badges_controller.rb new file mode 100644 index 00000000000..edb334a3d88 --- /dev/null +++ b/app/controllers/groups/settings/badges_controller.rb @@ -0,0 +1,13 @@ +module Groups + module Settings + class BadgesController < Groups::ApplicationController + include GrapeRouteHelpers::NamedRouteMatcher + + before_action :authorize_admin_group! + + def index + @badge_api_endpoint = api_v4_groups_badges_path(id: @group.id) + end + end + end +end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 283c3e5f1e0..5ac4b8710e2 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -173,7 +173,9 @@ class GroupsController < Groups::ApplicationController .new(@projects, offset: params[:offset].to_i, filter: event_filter) .to_a - Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?) + Events::RenderService + .new(current_user) + .execute(@events, atom_request: request.format.atom?) end def user_actions diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 7d6fe6a0232..67057b5b126 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -25,8 +25,7 @@ class JwtController < ApplicationController authenticate_with_http_basic do |login, password| @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip) - if @authentication_result.failed? || - (@authentication_result.actor.present? && !@authentication_result.actor.is_a?(User)) + if @authentication_result.failed? render_unauthorized end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 3d27ae18b17..ac71f72e624 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -53,13 +53,19 @@ class ProfilesController < Profiles::ApplicationController def update_username result = Users::UpdateService.new(current_user, user: @user, username: username_param).execute - options = if result[:status] == :success - { notice: "Username successfully changed" } - else - { alert: "Username change failed - #{result[:message]}" } - end + respond_to do |format| + if result[:status] == :success + message = s_("Profiles|Username successfully changed") - redirect_back_or_default(default: { action: 'show' }, options: options) + format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) } + format.json { render json: { message: message }, status: :ok } + else + message = s_("Profiles|Username change failed - %{message}") % { message: result[:message] } + + format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: message }) } + format.json { render json: { message: message }, status: :unprocessable_entity } + end + end end private diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 6d9b42a2c04..032bb2267e7 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -1,5 +1,6 @@ class Projects::ApplicationController < ApplicationController include RoutableActions + include ChecksCollaboration skip_before_action :authenticate_user! before_action :project @@ -31,14 +32,6 @@ class Projects::ApplicationController < ApplicationController @repository ||= project.repository end - def can_collaborate_with_project?(project = nil, ref: nil) - project ||= @project - - can?(current_user, :push_code, project) || - (current_user && current_user.already_forked?(project)) || - user_access(project).can_push_to_branch?(ref) - end - def authorize_action!(action) unless can?(current_user, action, project) return access_denied! @@ -91,9 +84,4 @@ class Projects::ApplicationController < ApplicationController def check_issues_available! return render_404 unless @project.feature_available?(:issues, current_user) end - - def user_access(project) - @user_access ||= {} - @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project) - end end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index effb484ef0f..b7f548e0e63 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -34,6 +34,7 @@ class Projects::CommitController < Projects::ApplicationController def pipelines @pipelines = @commit.pipelines.order(id: :desc) + @pipelines = @pipelines.where(ref: params[:ref]) if params[:ref] respond_to do |format| format.html diff --git a/app/controllers/projects/deploy_tokens_controller.rb b/app/controllers/projects/deploy_tokens_controller.rb new file mode 100644 index 00000000000..2f91b8f36de --- /dev/null +++ b/app/controllers/projects/deploy_tokens_controller.rb @@ -0,0 +1,10 @@ +class Projects::DeployTokensController < Projects::ApplicationController + before_action :authorize_admin_project! + + def revoke + @token = @project.deploy_tokens.find(params[:id]) + @token.revoke! + + redirect_to project_settings_repository_path(project) + end +end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 7bc16214010..8e86af43fee 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -4,8 +4,8 @@ class Projects::DiscussionsController < Projects::ApplicationController before_action :check_merge_requests_available! before_action :merge_request - before_action :discussion - before_action :authorize_resolve_discussion! + before_action :discussion, only: [:resolve, :unresolve] + before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve] def resolve Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion) diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index dd5e66f60e3..07249fe3182 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -7,6 +7,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController attr_reader :authentication_result, :redirected_path delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true + delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result alias_method :user, :actor alias_method :authenticated_user, :actor diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 45910a9be44..1dcf837f78e 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -64,7 +64,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities, namespace_path: params[:namespace_id], project_path: project_path, - redirected_path: redirected_path) + redirected_path: redirected_path, auth_result_type: auth_result_type) end def access_actor diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b14939c4216..767e492f566 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_update_issuable!, only: [:edit, :update, :move] # Allow create a new branch and empty WIP merge request from current issue - before_action :authorize_create_merge_request!, only: [:create_merge_request] + before_action :authorize_create_merge_request_from!, only: [:create_merge_request] respond_to :html diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 85e972d9731..dd12d30a085 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -2,7 +2,6 @@ class Projects::JobsController < Projects::ApplicationController include SendFileUpload before_action :build, except: [:index, :cancel_all] - before_action :authorize_read_build!, only: [:index, :show, :status, :raw, :trace] before_action :authorize_update_build!, @@ -45,8 +44,11 @@ class Projects::JobsController < Projects::ApplicationController end def show - @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') - @builds = @builds.where("id not in (?)", @build.id) + @builds = @project.pipelines + .find_by_sha(@build.sha) + .builds + .order('id DESC') + .present(current_user: current_user) @pipeline = @build.pipeline respond_to do |format| @@ -128,7 +130,7 @@ class Projects::JobsController < Projects::ApplicationController if stream.file? send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' else - render_404 + send_data stream.raw, type: 'text/plain; charset=utf-8', disposition: 'inline', filename: 'job.log' end end end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 516198b1b8a..91016f6494e 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -150,7 +150,8 @@ class Projects::LabelsController < Projects::ApplicationController end def find_labels - @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute + @available_labels ||= + LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: params[:include_ancestor_groups]).execute end def authorize_admin_labels! diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index c77f10ef1dd..ee4ed674110 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -41,7 +41,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController def existing_oids @existing_oids ||= begin - storage_project.lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid) + project.all_lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid) end end diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb index 2515e4b9a17..ebde0df1f7b 100644 --- a/app/controllers/projects/lfs_storage_controller.rb +++ b/app/controllers/projects/lfs_storage_controller.rb @@ -31,7 +31,9 @@ class Projects::LfsStorageController < Projects::GitHttpClientController render plain: 'Unprocessable entity', status: 422 end rescue ActiveRecord::RecordInvalid - render_400 + render_lfs_forbidden + rescue UploadedFile::InvalidPathError + render_lfs_forbidden rescue ObjectStorage::RemoteStoreError render_lfs_forbidden end @@ -66,10 +68,11 @@ class Projects::LfsStorageController < Projects::GitHttpClientController end def create_file!(oid, size) - LfsObject.new(oid: oid, size: size).tap do |object| - object.file.store_workhorse_file!(params, :file) - object.save! - end + uploaded_file = UploadedFile.from_params( + params, :file, LfsObjectUploader.workhorse_local_upload_path) + return unless uploaded_file + + LfsObject.create!(oid: oid, size: size, file: uploaded_file) end def link_to_project!(object) diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index a90030a8312..4a377fefc62 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -5,7 +5,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap skip_before_action :merge_request before_action :whitelist_query_limiting, only: [:create] - before_action :authorize_create_merge_request! + before_action :authorize_create_merge_request_from! before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path] before_action :build_merge_request, except: [:create] diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index dd41b9648e8..86c50d88a2a 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -68,7 +68,7 @@ class Projects::NotesController < Projects::ApplicationController private def render_json_with_notes_serializer - Notes::RenderService.new(current_user).execute([note], project) + Notes::RenderService.new(current_user).execute([note]) render json: note_serializer.represent(note) end diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 557671ab186..73c613b26f3 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -4,41 +4,4 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def show redirect_to project_settings_ci_cd_path(@project, params: params) end - - def update - Projects::UpdateService.new(project, current_user, update_params).tap do |service| - if service.execute - flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." - - run_autodevops_pipeline(service) - - redirect_to project_settings_ci_cd_path(@project) - else - render 'show' - end - end - end - - private - - def run_autodevops_pipeline(service) - return unless service.run_auto_devops_pipeline? - - if @project.empty_repo? - flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch." - return - end - - CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) - flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe - end - - def update_params - params.require(:project).permit( - :runners_token, :builds_enabled, :build_allow_git_fetch, - :build_timeout_in_minutes, :build_coverage_regex, :public_builds, - :auto_cancel_pending_pipelines, :ci_config_path, - auto_devops_attributes: [:id, :domain, :enabled] - ) - end end diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 2376f469213..48a09e1ddb8 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -25,7 +25,7 @@ class Projects::RefsController < Projects::ApplicationController when "graphs_commits" commits_project_graph_path(@project, @id) when "badges" - project_pipelines_settings_path(@project, ref: @id) + project_settings_ci_cd_path(@project, ref: @id) else project_commits_path(@project, @id) end diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index d5af0341d18..937b0e39cbd 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -1,6 +1,9 @@ class Projects::RepositoriesController < Projects::ApplicationController + include ExtractsPath + # Authorize before_action :require_non_empty_project, except: :create + before_action :assign_archive_vars, only: :archive before_action :authorize_download_code! before_action :authorize_admin_project!, only: :create @@ -11,9 +14,26 @@ class Projects::RepositoriesController < Projects::ApplicationController end def archive - send_git_archive @repository, ref: params[:ref], format: params[:format] + append_sha = params[:append_sha] + + if @ref + shortname = "#{@project.path}-#{@ref.tr('/', '-')}" + append_sha = false if @filename == shortname + end + + send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha rescue => ex logger.error("#{self.class.name}: #{ex}") return git_not_found! end + + def assign_archive_vars + @id = params[:id] + + return unless @id + + @ref, @filename = extract_ref(@id) + rescue InvalidPathError + render_404 + end end diff --git a/app/controllers/projects/settings/badges_controller.rb b/app/controllers/projects/settings/badges_controller.rb new file mode 100644 index 00000000000..f7b70dd4b7b --- /dev/null +++ b/app/controllers/projects/settings/badges_controller.rb @@ -0,0 +1,13 @@ +module Projects + module Settings + class BadgesController < Projects::ApplicationController + include GrapeRouteHelpers::NamedRouteMatcher + + before_action :authorize_admin_project! + + def index + @badge_api_endpoint = api_v4_projects_badges_path(id: @project.id) + end + end + end +end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 96125b549b7..d80ef8113aa 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -2,13 +2,24 @@ module Projects module Settings class CiCdController < Projects::ApplicationController before_action :authorize_admin_pipeline! + before_action :define_variables def show - define_runners_variables - define_secret_variables - define_triggers_variables - define_badges_variables - define_auto_devops_variables + end + + def update + Projects::UpdateService.new(project, current_user, update_params).tap do |service| + result = service.execute + if result[:status] == :success + flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." + + run_autodevops_pipeline(service) + + redirect_to project_settings_ci_cd_path(@project) + else + render 'show' + end + end end def reset_cache @@ -25,6 +36,35 @@ module Projects private + def update_params + params.require(:project).permit( + :runners_token, :builds_enabled, :build_allow_git_fetch, + :build_timeout_human_readable, :build_coverage_regex, :public_builds, + :auto_cancel_pending_pipelines, :ci_config_path, + auto_devops_attributes: [:id, :domain, :enabled] + ) + end + + def run_autodevops_pipeline(service) + return unless service.run_auto_devops_pipeline? + + if @project.empty_repo? + flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch." + return + end + + CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) + flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe + end + + def define_variables + define_runners_variables + define_secret_variables + define_triggers_variables + define_badges_variables + define_auto_devops_variables + end + def define_runners_variables @project_runners = @project.runners.ordered @assignable_runners = current_user.ci_authorized_runners diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index dd9e4a2af3e..f17056f13e0 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -4,13 +4,31 @@ module Projects before_action :authorize_admin_project! def show - @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user) + render_show + end - define_protected_refs + def create_deploy_token + @new_deploy_token = DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute + + if @new_deploy_token.persisted? + flash.now[:notice] = s_('DeployTokens|Your new project deploy token has been created.') + end + + render_show end private + def render_show + @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user) + @deploy_tokens = @project.deploy_tokens.active + + define_deploy_token + define_protected_refs + + render 'show' + end + def define_protected_refs @protected_branches = @project.protected_branches.order(:name).page(params[:page]) @protected_tags = @project.protected_tags.order(:name).page(params[:page]) @@ -51,6 +69,14 @@ module Projects gon.push(protectable_branches_for_dropdown) gon.push(access_levels_options) end + + def define_deploy_token + @new_deploy_token ||= DeployToken.new + end + + def deploy_token_params + params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry) + end end end end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index 7c19aa7bb23..208a1d19862 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -5,6 +5,8 @@ class Projects::SnippetsController < Projects::ApplicationController include SnippetsActions include RendersBlob + skip_before_action :verify_authenticity_token, only: [:show], if: :js_request? + before_action :check_snippets_available! before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam] @@ -71,6 +73,7 @@ class Projects::SnippetsController < Projects::ApplicationController format.json do render_blob_json(blob) end + format.js { render 'shared/snippets/show'} end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ee197c75764..37f14230196 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -324,7 +324,7 @@ class ProjectsController < Projects::ApplicationController :avatar, :build_allow_git_fetch, :build_coverage_regex, - :build_timeout_in_minutes, + :build_timeout_human_readable, :resolve_outdated_diff_discussions, :container_registry_enabled, :default_branch, diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index be2d3f638ff..3d51520ddf4 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -6,6 +6,8 @@ class SnippetsController < ApplicationController include RendersBlob include PreviewMarkdown + skip_before_action :verify_authenticity_token, only: [:show], if: :js_request? + before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] # Allow read snippet @@ -77,6 +79,8 @@ class SnippetsController < ApplicationController format.json do render_blob_json(blob) end + + format.js { render 'shared/snippets/show' } end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 61c72aa22a8..7ed9b1fc6d0 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -159,7 +159,10 @@ class IssuableFinder finder_options = { include_subgroups: params[:include_subgroups], only_owned: true } GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute else - ProjectsFinder.new(current_user: current_user, project_ids_relation: item_project_ids(items)).execute + opts = { current_user: current_user } + opts[:project_ids_relation] = item_project_ids(items) if items + + ProjectsFinder.new(opts).execute end @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) @@ -316,9 +319,9 @@ class IssuableFinder def by_project(items) items = if project? - items.of_projects(projects(items)).references_project - elsif projects(items) - items.merge(projects(items).reorder(nil)).join_project + items.of_projects(projects).references_project + elsif projects + items.merge(projects.reorder(nil)).join_project else items.none end diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index 780c0fdb03e..afd1f824b32 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -28,9 +28,10 @@ class LabelsFinder < UnionFinder if project if project.group.present? labels_table = Label.arel_table + group_ids = group_ids_for(project.group) label_ids << Label.where( - labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].eq(project.group.id)).or( + labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].in(group_ids)).or( labels_table[:type].eq('ProjectLabel').and(labels_table[:project_id].eq(project.id)) ) ) @@ -38,11 +39,14 @@ class LabelsFinder < UnionFinder label_ids << project.labels end end - elsif only_group_labels? - label_ids << Label.where(group_id: group_ids) else + if group? + group = Group.find(params[:group_id]) + label_ids << Label.where(group_id: group_ids_for(group)) + end + label_ids << Label.where(group_id: projects.group_ids) - label_ids << Label.where(project_id: projects.select(:id)) + label_ids << Label.where(project_id: projects.select(:id)) unless only_group_labels? end label_ids @@ -59,22 +63,33 @@ class LabelsFinder < UnionFinder items.where(title: title) end - def group_ids + # Gets redacted array of group ids + # which can include the ancestors and descendants of the requested group. + def group_ids_for(group) strong_memoize(:group_ids) do - groups_user_can_read_labels(groups_to_include).map(&:id) + groups = groups_to_include(group) + + groups_user_can_read_labels(groups).map(&:id) end end - def groups_to_include - group = Group.find(params[:group_id]) + def groups_to_include(group) groups = [group] - groups += group.ancestors if params[:include_ancestor_groups].present? - groups += group.descendants if params[:include_descendant_groups].present? + groups += group.ancestors if include_ancestor_groups? + groups += group.descendants if include_descendant_groups? groups end + def include_ancestor_groups? + params[:include_ancestor_groups] + end + + def include_descendant_groups? + params[:include_descendant_groups] + end + def group? params[:group_id].present? end diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb index f358938344e..188ec447a94 100644 --- a/app/finders/merge_request_target_project_finder.rb +++ b/app/finders/merge_request_target_project_finder.rb @@ -12,6 +12,7 @@ class MergeRequestTargetProjectFinder if @source_project.fork_network @source_project.fork_network.projects .public_or_visible_to_user(current_user) + .non_archived .with_feature_available_for_user(:merge_requests, current_user) else Project.where(id: source_project) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 86ec500ceb3..228c8d2e8f9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -228,9 +228,7 @@ module ApplicationHelper scope: params[:scope], milestone_title: params[:milestone_title], assignee_id: params[:assignee_id], - assignee_username: params[:assignee_username], author_id: params[:author_id], - author_username: params[:author_username], search: params[:search], label_name: params[:label_name] } diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index b3b080e6dcf..3fbb32c5229 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -74,10 +74,12 @@ module ApplicationSettingsHelper css_class = 'btn' css_class << ' active' unless disabled checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]' + name = Gitlab::Auth::OAuth::Provider.label_for(source) label_tag(checkbox_name, class: css_class) do check_box_tag(checkbox_name, source, !disabled, - autocomplete: 'off') + Gitlab::Auth::OAuth::Provider.label_for(source) + autocomplete: 'off', + id: name.tr(' ', '_')) + name end end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 2b440e4d584..866b8773db6 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -59,7 +59,7 @@ module BlobHelper button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' } elsif can_modify_blob?(blob, project, ref) button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' - elsif can?(current_user, :fork_project, project) + elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project) edit_fork_button_tag(common_classes, project, label, edit_modify_file_fork_params(action), action) end end @@ -280,7 +280,7 @@ module BlobHelper options << link_to("submit an issue", new_project_issue_path(project)) end - merge_project = can?(current_user, :create_merge_request, project) ? project : (current_user && current_user.fork_of(project)) + merge_project = merge_request_source_project_for_project(@project) if merge_project options << link_to("create a merge request", project_new_merge_request_path(project)) end @@ -334,7 +334,7 @@ module BlobHelper # Web IDE (Beta) requires the user to have this feature enabled elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) edit_link_tag(text, edit_path, common_classes) - elsif current_user && can?(current_user, :fork_project, project) + elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project) edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path)) end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 275e892b2e6..af878bcf9a0 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -53,10 +53,12 @@ module BoardsHelper end def board_list_data + include_descendant_groups = @group&.present? + { toggle: "dropdown", - list_labels_path: labels_filter_path(true), - labels: labels_filter_path(true), + list_labels_path: labels_filter_path(true, include_ancestor_groups: true), + labels: labels_filter_path(true, include_descendant_groups: include_descendant_groups), labels_endpoint: @labels_endpoint, namespace_path: @namespace_path, project_path: @project&.path, diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 636316da80a..f0afcac5986 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -94,7 +94,7 @@ module CiStatusHelper def render_project_pipeline_status(pipeline_status, tooltip_placement: 'auto left') project = pipeline_status.project - path = pipelines_project_commit_path(project, pipeline_status.sha) + path = pipelines_project_commit_path(project, pipeline_status.sha, ref: pipeline_status.ref) render_status_with_link( 'commit', @@ -105,7 +105,7 @@ module CiStatusHelper def render_commit_status(commit, ref: nil, tooltip_placement: 'auto left') project = commit.project - path = pipelines_project_commit_path(project, commit) + path = pipelines_project_commit_path(project, commit, ref: ref) render_status_with_link( 'commit', diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 0333c29e2fd..98894b86551 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -93,25 +93,18 @@ module CommitsHelper return unless current_controller?(:commits) if @path.blank? - return link_to( - _("Browse Files"), - project_tree_path(project, commit), - class: "btn btn-default" - ) + url = project_tree_path(project, commit) + tooltip = _("Browse Files") elsif @repo.blob_at(commit.id, @path) - return link_to( - _("Browse File"), - project_blob_path(project, - tree_join(commit.id, @path)), - class: "btn btn-default" - ) + url = project_blob_path(project, tree_join(commit.id, @path)) + tooltip = _("Browse File") elsif @path.present? - return link_to( - _("Browse Directory"), - project_tree_path(project, - tree_join(commit.id, @path)), - class: "btn btn-default" - ) + url = project_tree_path(project, tree_join(commit.id, @path)) + tooltip = _("Browse Directory") + end + + link_to url, class: "btn btn-default has-tooltip", title: tooltip, data: { container: "body" } do + sprite_icon('folder-open') end end @@ -170,7 +163,7 @@ module CommitsHelper tooltip = "#{action.capitalize} this #{commit.change_type_title(current_user)} in a new merge request" if has_tooltip btn_class = "btn btn-#{btn_class}" unless btn_class.nil? - if can_collaborate_with_project? + if can_collaborate_with_project?(@project) link_to action.capitalize, "#modal-#{action}-commit", 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}" elsif can?(current_user, :fork_project, @project) continue_params = { diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb index 8bf96c0905f..2df5b5d1695 100644 --- a/app/helpers/compare_helper.rb +++ b/app/helpers/compare_helper.rb @@ -3,7 +3,7 @@ module CompareHelper from.present? && to.present? && from != to && - can?(current_user, :create_merge_request, project) && + can?(current_user, :create_merge_request_from, project) && project.repository.branch_exists?(from) && project.repository.branch_exists?(to) end diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb new file mode 100644 index 00000000000..bd921322476 --- /dev/null +++ b/app/helpers/deploy_tokens_helper.rb @@ -0,0 +1,12 @@ +module DeployTokensHelper + def expand_deploy_tokens_section?(deploy_token) + deploy_token.persisted? || + deploy_token.errors.present? || + Rails.env.test? + end + + def container_registry_enabled?(project) + Gitlab.config.registry.enabled && + can?(current_user, :read_container_image, project) + end +end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 16eceb3f48f..95fea2f18d1 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,6 +1,6 @@ module GroupsHelper def group_nav_link_paths - %w[groups#projects groups#edit ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index] + %w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index] end def group_sidebar_links diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index c5522ff7a69..2f304b040c7 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -43,6 +43,10 @@ module IconsHelper content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes) end + def external_snippet_icon(name) + content_tag(:span, "", class: "gl-snippet-icon gl-snippet-icon-#{name}") + end + def audit_icon(names, options = {}) case names when "standard" diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 6d6b840f485..06c3e569c84 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -159,16 +159,18 @@ module IssuablesHelper label_names.join(', ') end - def issuables_state_counter_text(issuable_type, state) + def issuables_state_counter_text(issuable_type, state, display_count) titles = { opened: "Open" } state_title = titles[state] || state.to_s.humanize - count = issuables_count_for_state(issuable_type, state) - html = content_tag(:span, state_title) - html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge') + + if display_count + count = issuables_count_for_state(issuable_type, state) + html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge') + end html.html_safe end @@ -191,24 +193,10 @@ module IssuablesHelper end end - def issuable_filter_params - [ - :search, - :author_id, - :assignee_id, - :milestone_title, - :label_name - ] - end - def issuable_reference(issuable) @show_full_reference ? issuable.to_reference(full: true) : issuable.to_reference(@group || @project) end - def issuable_filter_present? - issuable_filter_params.any? { |k| params.key?(k) } - end - def issuable_initial_data(issuable) data = { endpoint: issuable_path(issuable), diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 0f25d401406..96dc7ae1185 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -82,8 +82,8 @@ module IssuesHelper names.to_sentence end - def award_state_class(awards, current_user) - if !current_user + def award_state_class(awardable, awards, current_user) + if !can?(current_user, :award_emoji, awardable) "disabled" elsif current_user && awards.find { |a| a.user_id == current_user.id } "active" @@ -126,6 +126,17 @@ module IssuesHelper link_to link_text, path end + def show_new_issue_link?(project) + return false unless project + return false if project.archived? + + # We want to show the link to users that are not signed in, that way they + # get directed to the sign-in/sign-up flow and afterwards to the new issue page. + return true unless current_user + + can?(current_user, :create_issue, project) + end + # Required for Banzai::Filter::IssueReferenceFilter module_function :url_for_issue module_function :url_for_internal_issue diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 87ff607dc3f..c4a6a1e4bb3 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -129,13 +129,17 @@ module LabelsHelper end end - def labels_filter_path(only_group_labels = false) + def labels_filter_path(only_group_labels = false, include_ancestor_groups: true, include_descendant_groups: false) project = @target_project || @project + options = {} + options[:include_ancestor_groups] = include_ancestor_groups if include_ancestor_groups + options[:include_descendant_groups] = include_descendant_groups if include_descendant_groups + if project - project_labels_path(project, :json) + project_labels_path(project, :json, options) elsif @group - options = { only_group_labels: only_group_labels } if only_group_labels + options[:only_group_labels] = only_group_labels if only_group_labels group_labels_path(@group, :json, options) else dashboard_labels_path(:json) diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 2fe1927a189..39e7a7fd396 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -256,7 +256,7 @@ module MarkupHelper return '' unless html.present? context.merge!( - current_user: (current_user if defined?(current_user)), + current_user: (current_user if defined?(current_user)), # RelativeLinkFilter commit: @commit, diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index fb4fe1c40b7..c19c5b9cc82 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -138,6 +138,18 @@ module MergeRequestsHelper end end + def merge_request_source_project_for_project(project = @project) + unless can?(current_user, :create_merge_request_in, project) + return nil + end + + if can?(current_user, :create_merge_request_from, project) + project + else + current_user.fork_of(project) + end + end + def merge_params_ee(merge_request) {} end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 27ed48fdbc7..7f67574a428 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -6,10 +6,6 @@ module NotesHelper end end - def note_editable?(note) - Ability.can_edit_note?(current_user, note) - end - def note_supports_quick_actions?(note) Notes::QuickActionsService.supported?(note) end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 15f48e43a28..a64b2acdd77 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -157,40 +157,6 @@ module ProjectsHelper current_user&.recent_push(@project) end - def project_feature_access_select(field) - # Don't show option "everyone with access" if project is private - options = project_feature_options - - level = @project.project_feature.public_send(field) # rubocop:disable GitlabSecurity/PublicSend - - if @project.private? - disabled_option = ProjectFeature::ENABLED - highest_available_option = ProjectFeature::PRIVATE if level == disabled_option - end - - options = options_for_select( - options.invert, - selected: highest_available_option || level, - disabled: disabled_option - ) - - content_tag :div, class: "select-wrapper" do - concat( - content_tag( - :select, - options, - name: "project[project_feature_attributes][#{field}]", - id: "project_project_feature_attributes_#{field}", - class: "pull-right form-control select-control #{repo_children_classes(field)} ", - data: { field: field } - ) - ) - concat( - icon('chevron-down') - ) - end.html_safe - end - def link_to_autodeploy_doc link_to _('About auto deploy'), help_page_path('ci/autodeploy/index'), target: '_blank' end @@ -274,16 +240,6 @@ module ProjectsHelper private - def repo_children_classes(field) - needs_repo_check = [:merge_requests_access_level, :builds_access_level] - return unless needs_repo_check.include?(field) - - classes = "project-repo-select js-repo-select" - classes << " disabled" unless @project.feature_available?(:repository, current_user) - - classes - end - def get_project_nav_tabs(project, current_user) nav_tabs = [:home] @@ -447,14 +403,6 @@ module ProjectsHelper filtered_message.gsub(project.repository_storage_path.chomp('/'), "[REPOS PATH]") end - def project_feature_options - { - ProjectFeature::DISABLED => s_('ProjectFeature|Disabled'), - ProjectFeature::PRIVATE => s_('ProjectFeature|Only team members'), - ProjectFeature::ENABLED => s_('ProjectFeature|Everyone with access') - } - end - def project_child_container_class(view_path) view_path == "projects/issues/issues" ? "prepend-top-default" : "project-show-#{view_path}" end @@ -463,20 +411,6 @@ module ProjectsHelper IssuesFinder.new(current_user, project_id: project.id).execute end - def visibility_select_options(project, selected_level) - level_options = Gitlab::VisibilityLevel.values.each_with_object([]) do |level, level_options| - next if restricted_levels.include?(level) - - level_options << [ - visibility_level_label(level), - { data: { description: visibility_level_description(level, project) } }, - level - ] - end - - options_for_select(level_options, selected_level) - end - def restricted_levels return [] if current_user.admin? diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index f435c80c656..f872990122e 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -1,4 +1,29 @@ module ServicesHelper + def service_event_description(event) + case event + when "push", "push_events" + "Event will be triggered by a push to the repository" + when "tag_push", "tag_push_events" + "Event will be triggered when a new tag is pushed to the repository" + when "note", "note_events" + "Event will be triggered when someone adds a comment" + when "confidential_note", "confidential_note_events" + "Event will be triggered when someone adds a comment on a confidential issue" + when "issue", "issue_events" + "Event will be triggered when an issue is created/updated/closed" + when "confidential_issue", "confidential_issues_events" + "Event will be triggered when a confidential issue is created/updated/closed" + when "merge_request", "merge_request_events" + "Event will be triggered when a merge request is created/updated/merged" + when "pipeline", "pipeline_events" + "Event will be triggered when a pipeline status changes" + when "wiki_page", "wiki_page_events" + "Event will be triggered when a wiki page is created/updated" + when "commit", "commit_events" + "Event will be triggered when a commit is created/updated" + end + end + def service_event_field_name(event) event = event.pluralize if %w[merge_request issue confidential_issue].include?(event) "#{event}_events" diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 00e7e4230b9..733832c1bbb 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -101,4 +101,39 @@ module SnippetsHelper # Return snippet with chunk array { snippet_object: snippet, snippet_chunks: snippet_chunks } end + + def snippet_embed + "<script src=\"#{url_for(only_path: false, overwrite_params: nil)}.js\"></script>" + end + + def embedded_snippet_raw_button + blob = @snippet.blob + return if blob.empty? || blob.raw_binary? || blob.stored_externally? + + snippet_raw_url = if @snippet.is_a?(PersonalSnippet) + raw_snippet_url(@snippet) + else + raw_project_snippet_url(@snippet.project, @snippet) + end + + link_to external_snippet_icon('doc_code'), snippet_raw_url, class: 'btn', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw' + end + + def embedded_snippet_download_button + download_url = if @snippet.is_a?(PersonalSnippet) + raw_snippet_url(@snippet, inline: false) + else + raw_project_snippet_url(@snippet.project, @snippet, inline: false) + end + + link_to external_snippet_icon('download'), download_url, class: 'btn', target: '_blank', title: 'Download', rel: 'noopener noreferrer' + end + + def public_snippet? + if @snippet.project_id? + can?(nil, :read_project_snippet, @snippet) + else + can?(nil, :read_personal_snippet, @snippet) + end + end end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index b64be89c181..dc42caa70e5 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -90,7 +90,7 @@ module TreeHelper end def commit_in_single_accessible_branch - branch_name = html_escape(selected_branch) + branch_name = ERB::Util.html_escape(selected_branch) message = _("Your changes can be committed to %{branch_name} because a merge "\ "request is open.") % { branch_name: "<strong>#{branch_name}</strong>" } @@ -123,7 +123,7 @@ module TreeHelper # returns the relative path of the first subdir that doesn't have only one directory descendant def flatten_tree(root_path, tree) - return tree.flat_path.sub(%r{\A#{root_path}/}, '') if tree.flat_path.present? + return tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '') if tree.flat_path.present? subtree = Gitlab::Git::Tree.where(@repository, @commit.id, tree.path) if subtree.count == 1 && subtree.first.dir? diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index 88f374be1e5..9f78b80c71d 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -24,8 +24,8 @@ module WorkhorseHelper end # Archive a Git repository and send it through Workhorse - def send_git_archive(repository, ref:, format:) - headers.store(*Gitlab::Workhorse.send_git_archive(repository, ref: ref, format: format)) + def send_git_archive(repository, **kwargs) + headers.store(*Gitlab::Workhorse.send_git_archive(repository, **kwargs)) head :ok end diff --git a/app/models/ability.rb b/app/models/ability.rb index 6dae49f38dc..618d4af4272 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -46,10 +46,6 @@ class Ability end end - def can_edit_note?(user, note) - allowed?(user, :edit_note, note) - end - def allowed?(user, action, subject = :global, opts = {}) if subject.is_a?(Hash) opts, subject = subject, :global diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 2a6406d63c7..fb66dd0b766 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -16,7 +16,7 @@ class Appearance < ActiveRecord::Base has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - CACHE_KEY = 'current_appearance'.freeze + CACHE_KEY = "current_appearance:#{Gitlab::VERSION}".freeze after_commit :flush_redis_cache diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 0b561203914..4aa236555cb 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -19,7 +19,7 @@ class BroadcastMessage < ActiveRecord::Base after_commit :flush_redis_cache def self.current - messages = Rails.cache.fetch(CACHE_KEY) { current_and_future_messages.to_a } + messages = Rails.cache.fetch(CACHE_KEY, expires_in: cache_expires_in) { current_and_future_messages.to_a } return messages if messages.empty? @@ -36,6 +36,10 @@ class BroadcastMessage < ActiveRecord::Base where('ends_at > :now', now: Time.zone.now).order_id_asc end + def self.cache_expires_in + nil + end + def active? started? && !ended? end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 18e96389199..4aa65bf4273 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -90,6 +90,7 @@ module Ci before_save :ensure_token before_destroy { unscoped_project } + before_create :ensure_metadata after_create unless: :importing? do |build| run_after_commit { BuildHooksWorker.perform_async(build.id) } end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 62d768cc6cf..44cb583e1bd 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -4,7 +4,7 @@ module Ci include HasVariable include Presentable - belongs_to :group + belongs_to :group, class_name: "::Group" alias_attribute :secret_value, :value diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index df57b4f65e3..fbb95fe16df 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -7,6 +7,7 @@ module Ci belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id + before_save :update_file_store before_save :set_size, if: :file_changed? scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } @@ -21,6 +22,10 @@ module Ci trace: 3 } + def update_file_store + self.file_store = file.object_store + end + def self.artifacts_size_for(project) self.where(project: project).sum(:size) end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 5a4c56ec0dc..ee0d8df8eb7 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -13,7 +13,7 @@ module Ci has_many :builds has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :projects, through: :runner_projects + has_many :projects, -> { auto_include(false) }, through: :runner_projects has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 77947d515c1..e4a06f3f976 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -15,7 +15,7 @@ module Clusters belongs_to :user has_many :cluster_projects, class_name: 'Clusters::Project' - has_many :projects, through: :cluster_projects, class_name: '::Project' + has_many :projects, -> { auto_include(false) }, through: :cluster_projects, class_name: '::Project' # we force autosave to happen when we save `Cluster` model has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true diff --git a/app/models/commit.rb b/app/models/commit.rb index b64462fb768..de860df4b9c 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -30,9 +30,12 @@ class Commit MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze + # Used by GFM to match and present link extensions on node texts and hrefs. + LINK_EXTENSION_PATTERN = /(patch)/.freeze def banzai_render_context(field) - context = { pipeline: :single_line, project: self.project } + pipeline = field == :description ? :commit_description : :single_line + context = { pipeline: pipeline, project: self.project } context[:author] = self.author if self.author context @@ -142,7 +145,8 @@ class Commit end def self.link_reference_pattern - @link_reference_pattern ||= super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})/) + @link_reference_pattern ||= + super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/) end def to_reference(from = nil, full: false) diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index d8394415362..fce37e7f78e 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -79,11 +79,7 @@ module Awardable end def user_can_award?(current_user, name) - if user_authored?(current_user) - !awardable_votes?(normalize_name(name)) - else - true - end + awardable_by_user?(current_user, name) && Ability.allowed?(current_user, :award_emoji, self) end def user_authored?(current_user) @@ -119,4 +115,12 @@ module Awardable def normalize_name(name) Gitlab::Emoji.normalize_emoji_name(name) end + + def awardable_by_user?(current_user, name) + if user_authored?(current_user) + !awardable_votes?(normalize_name(name)) + else + true + end + end end diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb index fa1eafb1d7a..593a9b3d71d 100644 --- a/app/models/concerns/chronic_duration_attribute.rb +++ b/app/models/concerns/chronic_duration_attribute.rb @@ -8,14 +8,14 @@ module ChronicDurationAttribute end end - def chronic_duration_attr_writer(virtual_attribute, source_attribute) + def chronic_duration_attr_writer(virtual_attribute, source_attribute, parameters = {}) chronic_duration_attr_reader(virtual_attribute, source_attribute) define_method("#{virtual_attribute}=") do |value| - chronic_duration_attributes[virtual_attribute] = value.presence || '' + chronic_duration_attributes[virtual_attribute] = value.presence || parameters[:default].presence.to_s begin - new_value = ChronicDuration.parse(value).to_i if value.present? + new_value = value.present? ? ChronicDuration.parse(value).to_i : parameters[:default].presence assign_attributes(source_attribute => new_value) rescue ChronicDuration::DurationParseError # ignore error as it will be caught by validation diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index b45395343cc..d9416352f9c 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -48,7 +48,7 @@ module Issuable end has_many :label_links, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :labels, through: :label_links + has_many :labels, -> { auto_include(false) }, through: :label_links has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :metrics diff --git a/app/models/concerns/presentable.rb b/app/models/concerns/presentable.rb index 7b33b837004..bc4fbd19a02 100644 --- a/app/models/concerns/presentable.rb +++ b/app/models/concerns/presentable.rb @@ -1,4 +1,12 @@ module Presentable + extend ActiveSupport::Concern + + class_methods do + def present(attributes) + all.map { |klass_object| klass_object.present(attributes) } + end + end + def present(**attributes) Gitlab::View::Presenter::Factory .new(self, attributes) diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index 7c236369793..399abb67c9d 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -102,7 +102,7 @@ module ResolvableDiscussion yield(notes_relation) # Set the notes array to the updated notes - @notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables + @notes = notes_relation.fresh.auto_include(false).to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables self.class.memoized_values.each do |name| clear_memoization(name) diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 89a74b7dcb1..858b7ef533e 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -2,7 +2,7 @@ class DeployKey < Key include IgnorableColumn has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :projects, through: :deploy_keys_projects + has_many :projects, -> { auto_include(false) }, through: :deploy_keys_projects scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) } scope :are_public, -> { where(public: true) } diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb new file mode 100644 index 00000000000..8dae821a10e --- /dev/null +++ b/app/models/deploy_token.rb @@ -0,0 +1,61 @@ +class DeployToken < ActiveRecord::Base + include Expirable + include TokenAuthenticatable + add_authentication_token_field :token + + AVAILABLE_SCOPES = %i(read_repository read_registry).freeze + + default_value_for(:expires_at) { Forever.date } + + has_many :project_deploy_tokens, inverse_of: :deploy_token + has_many :projects, -> { auto_include(false) }, through: :project_deploy_tokens + + validate :ensure_at_least_one_scope + before_save :ensure_token + + accepts_nested_attributes_for :project_deploy_tokens + + scope :active, -> { where("revoked = false AND expires_at >= NOW()") } + + def revoke! + update!(revoked: true) + end + + def active? + !revoked + end + + def scopes + AVAILABLE_SCOPES.select { |token_scope| read_attribute(token_scope) } + end + + def username + "gitlab+deploy-token-#{id}" + end + + def has_access_to?(requested_project) + active? && project == requested_project + end + + # This is temporal. Currently we limit DeployToken + # to a single project, later we're going to extend + # that to be for multiple projects and namespaces. + def project + projects.first + end + + def expires_at + expires_at = read_attribute(:expires_at) + expires_at != Forever.date ? expires_at : nil + end + + def expires_at=(value) + write_attribute(:expires_at, value.presence || Forever.date) + end + + private + + def ensure_at_least_one_scope + errors.add(:base, "Scopes can't be blank") unless read_repository || read_registry + end +end diff --git a/app/models/environment.rb b/app/models/environment.rb index 9517723d9d9..fddb269af4b 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -224,7 +224,7 @@ class Environment < ActiveRecord::Base end def deployment_platform - project.deployment_platform(environment: self) + project.deployment_platform(environment: self.name) end private diff --git a/app/models/event.rb b/app/models/event.rb index 3805f6cf857..741a84194e2 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -110,7 +110,10 @@ class Event < ActiveRecord::Base end end + # Remove this method when removing Gitlab.rails5? code. def subclass_from_attributes(attrs) + return super if Gitlab.rails5? + # Without this Rails will keep calling this method on the returned class, # resulting in an infinite loop. return unless self == Event diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb index 7f1728e8c77..aad3509b895 100644 --- a/app/models/fork_network.rb +++ b/app/models/fork_network.rb @@ -1,7 +1,7 @@ class ForkNetwork < ActiveRecord::Base belongs_to :root_project, class_name: 'Project' has_many :fork_network_members - has_many :projects, through: :fork_network_members + has_many :projects, -> { auto_include(false) }, through: :fork_network_members after_create :add_root_as_member, if: :root_project diff --git a/app/models/group.rb b/app/models/group.rb index 3cfe21ac93b..202988d743d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -12,9 +12,9 @@ class Group < Namespace has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members - has_many :users, through: :group_members + has_many :users, -> { auto_include(false) }, through: :group_members has_many :owners, - -> { where(members: { access_level: Gitlab::Access::OWNER }) }, + -> { where(members: { access_level: Gitlab::Access::OWNER }).auto_include(false) }, through: :group_members, source: :user @@ -23,7 +23,7 @@ class Group < Namespace has_many :milestones has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :shared_projects, through: :project_group_links, source: :project + has_many :shared_projects, -> { auto_include(false) }, through: :project_group_links, source: :project has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :labels, class_name: 'GroupLabel' has_many :variables, class_name: 'Ci::GroupVariable' @@ -286,6 +286,10 @@ class Group < Namespace false end + def refresh_project_authorizations + refresh_members_authorized_projects(blocking: false) + end + private def update_two_factor_requirement diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index b6dd39b860b..ec072882cc9 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -7,6 +7,7 @@ class ProjectHook < WebHook :issue_hooks, :confidential_issue_hooks, :note_hooks, + :confidential_note_hooks, :merge_request_hooks, :job_hooks, :pipeline_hooks, diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index cbec735c2dd..96a43006642 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -23,9 +23,12 @@ class InternalId < ActiveRecord::Base # # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL). # As such, the increment is atomic and safe to be called concurrently. - def increment_and_save! + # + # If a `maximum_iid` is passed in, this overrides the incremented value if it's + # greater than that. This can be used to correct the increment value if necessary. + def increment_and_save!(maximum_iid) lock! - self.last_value = (last_value || 0) + 1 + self.last_value = [(last_value || 0) + 1, (maximum_iid || 0) + 1].max save! last_value end @@ -89,7 +92,16 @@ class InternalId < ActiveRecord::Base # and increment its last value # # Note this will acquire a ROW SHARE lock on the InternalId record - (lookup || create_record).increment_and_save! + + # Note we always calculate the maximum iid present here and + # pass it in to correct the InternalId entry if it's last_value is off. + # + # This can happen in a transition phase where both `AtomicInternalId` and + # `NonatomicInternalId` code runs (e.g. during a deploy). + # + # This is subject to be cleaned up with the 10.8 release: + # https://gitlab.com/gitlab-org/gitlab-ce/issues/45389. + (lookup || create_record).increment_and_save!(maximum_iid) end end @@ -115,11 +127,15 @@ class InternalId < ActiveRecord::Base InternalId.create!( **scope, usage: usage_value, - last_value: init.call(subject) || 0 + last_value: maximum_iid ) end rescue ActiveRecord::RecordNotUnique lookup end + + def maximum_iid + @maximum_iid ||= init.call(subject) || 0 + end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 13abc6c1a0d..c1ffe6512ea 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -34,7 +34,7 @@ class Issue < ActiveRecord::Base dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :issue_assignees - has_many :assignees, class_name: "User", through: :issue_assignees + has_many :assignees, -> { auto_include(false) }, class_name: "User", through: :issue_assignees validates :project, presence: true @@ -272,11 +272,17 @@ class Issue < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - if options.key?(:sidebar_endpoints) && project + if options.key?(:issue_endpoints) && project url_helper = Gitlab::Routing.url_helpers - json.merge!(issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'), - toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self)) + issue_reference = options[:include_full_project_path] ? to_reference(full: true) : to_reference + + json.merge!( + reference_path: issue_reference, + real_path: url_helper.project_issue_path(project, self), + issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'), + toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self) + ) end if options.key?(:labels) diff --git a/app/models/label.rb b/app/models/label.rb index de7f1d56c64..f3496884cff 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -18,8 +18,8 @@ class Label < ActiveRecord::Base has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :priorities, class_name: 'LabelPriority' has_many :label_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :issues, through: :label_links, source: :target, source_type: 'Issue' - has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' + has_many :issues, -> { auto_include(false) }, through: :label_links, source: :target, source_type: 'Issue' + has_many :merge_requests, -> { auto_include(false) }, through: :label_links, source: :target, source_type: 'MergeRequest' before_validation :strip_whitespace_from_title_and_color diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index b7de46fa202..ed95613ee59 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -3,7 +3,7 @@ class LfsObject < ActiveRecord::Base include ObjectStorage::BackgroundMove has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :projects, through: :lfs_objects_projects + has_many :projects, -> { auto_include(false) }, through: :lfs_objects_projects scope :with_files_stored_locally, -> { where(file_store: [nil, LfsObjectUploader::Store::LOCAL]) } diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index b75387e236e..1c2e57bb01f 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -17,7 +17,7 @@ class MergeRequestDiffCommit < ActiveRecord::Base commit_hash.merge( merge_request_diff_id: merge_request_diff_id, relative_order: index, - sha: sha_attribute.type_cast_for_database(sha), + sha: sha_attribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]), committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]) ) diff --git a/app/models/milestone.rb b/app/models/milestone.rb index dafae58d121..8e33bab81c1 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -22,7 +22,7 @@ class Milestone < ActiveRecord::Base belongs_to :group has_many :issues - has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues + has_many :labels, -> { distinct.reorder('labels.title').auto_include(false) }, through: :issues has_many :merge_requests has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -34,8 +34,8 @@ class Milestone < ActiveRecord::Base scope :for_projects_and_groups, -> (project_ids, group_ids) do conditions = [] - conditions << arel_table[:project_id].in(project_ids) if project_ids.compact.any? - conditions << arel_table[:group_id].in(group_ids) if group_ids.compact.any? + conditions << arel_table[:project_id].in(project_ids) if project_ids&.compact&.any? + conditions << arel_table[:group_id].in(group_ids) if group_ids&.compact&.any? where(conditions.reduce(:or)) end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index e350b675639..2b63aa33222 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -252,6 +252,10 @@ class Namespace < ActiveRecord::Base [] end + def refresh_project_authorizations + owner.refresh_authorized_projects + end + private def path_or_parent_changed? diff --git a/app/models/note.rb b/app/models/note.rb index 0f5fb529a87..e426f84832b 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -268,6 +268,10 @@ class Note < ActiveRecord::Base self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR end + def confidential? + noteable.try(:confidential?) + end + def editable? !system? end @@ -313,6 +317,10 @@ class Note < ActiveRecord::Base !system? && !for_snippet? end + def can_create_notification? + true + end + def discussion_class(noteable = nil) # When commit notes are rendered on an MR's Discussion page, they are # displayed in one discussion instead of individually. diff --git a/app/models/project.rb b/app/models/project.rb index 714a15ade9c..ffd78d3ab70 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -21,6 +21,7 @@ class Project < ActiveRecord::Base include Gitlab::SQL::Pattern include DeploymentPlatform include ::Gitlab::Utils::StrongMemoize + include ChronicDurationAttribute extend Gitlab::ConfigHelper @@ -137,11 +138,11 @@ class Project < ActiveRecord::Base has_one :packagist_service # TODO: replace these relations with the fork network versions - has_one :forked_project_link, foreign_key: "forked_to_project_id" - has_one :forked_from_project, through: :forked_project_link + has_one :forked_project_link, foreign_key: "forked_to_project_id" + has_one :forked_from_project, -> { auto_include(false) }, through: :forked_project_link has_many :forked_project_links, foreign_key: "forked_from_project_id" - has_many :forks, through: :forked_project_links, source: :forked_to_project + has_many :forks, -> { auto_include(false) }, through: :forked_project_links, source: :forked_to_project # TODO: replace these relations with the fork network versions has_one :root_of_fork_network, @@ -149,7 +150,7 @@ class Project < ActiveRecord::Base inverse_of: :root_project, class_name: 'ForkNetwork' has_one :fork_network_member - has_one :fork_network, through: :fork_network_member + has_one :fork_network, -> { auto_include(false) }, through: :fork_network_member # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id' @@ -166,27 +167,27 @@ class Project < ActiveRecord::Base has_many :protected_tags has_many :project_authorizations - has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User' + has_many :authorized_users, -> { auto_include(false) }, through: :project_authorizations, source: :user, class_name: 'User' has_many :project_members, -> { where(requested_at: nil) }, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :project_members - has_many :users, through: :project_members + has_many :users, -> { auto_include(false) }, through: :project_members has_many :requesters, -> { where.not(requested_at: nil) }, as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :members_and_requesters, as: :source, class_name: 'ProjectMember' has_many :deploy_keys_projects - has_many :deploy_keys, through: :deploy_keys_projects + has_many :deploy_keys, -> { auto_include(false) }, through: :deploy_keys_projects has_many :users_star_projects - has_many :starrers, through: :users_star_projects, source: :user + has_many :starrers, -> { auto_include(false) }, through: :users_star_projects, source: :user has_many :releases has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :lfs_objects, through: :lfs_objects_projects + has_many :lfs_objects, -> { auto_include(false) }, through: :lfs_objects_projects has_many :lfs_file_locks has_many :project_group_links - has_many :invited_groups, through: :project_group_links, source: :group + has_many :invited_groups, -> { auto_include(false) }, through: :project_group_links, source: :group has_many :pages_domains has_many :todos has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -198,7 +199,7 @@ class Project < ActiveRecord::Base has_one :statistics, class_name: 'ProjectStatistics' has_one :cluster_project, class_name: 'Clusters::Project' - has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' + has_many :clusters, -> { auto_include(false) }, through: :cluster_project, class_name: 'Clusters::Cluster' # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -215,14 +216,16 @@ class Project < ActiveRecord::Base has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :runner_projects, class_name: 'Ci::RunnerProject' - has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' + has_many :runners, -> { auto_include(false) }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, class_name: 'Ci::Variable' has_many :triggers, class_name: 'Ci::Trigger' has_many :environments has_many :deployments has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' + has_many :project_deploy_tokens + has_many :deploy_tokens, -> { auto_include(false) }, through: :project_deploy_tokens - has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' + has_many :active_runners, -> { active.auto_include(false) }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_one :auto_devops, class_name: 'ProjectAutoDevops' has_many :custom_attributes, class_name: 'ProjectCustomAttribute' @@ -325,6 +328,12 @@ class Project < ActiveRecord::Base enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } + chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600 + + validates :build_timeout, allow_nil: true, + numericality: { greater_than_or_equal_to: 600, + message: 'needs to be at least 10 minutes' } + # Returns a collection of projects that is either public or visible to the # logged in user. def self.public_or_visible_to_user(user = nil) @@ -630,7 +639,7 @@ class Project < ActiveRecord::Base end def create_or_update_import_data(data: nil, credentials: nil) - return unless import_url.present? && valid_import_url? + return if data.nil? && credentials.nil? project_import_data = import_data || build_import_data if data @@ -1066,6 +1075,16 @@ class Project < ActiveRecord::Base end end + # This will return all `lfs_objects` that are accessible to the project. + # So this might be `self.lfs_objects` if the project is not part of a fork + # network, or it is the base of the fork network. + # + # TODO: refactor this to get the correct lfs objects when implementing + # https://gitlab.com/gitlab-org/gitlab-ce/issues/39769 + def all_lfs_objects + lfs_storage_project.lfs_objects + end + def personal? !group end @@ -1299,14 +1318,6 @@ class Project < ActiveRecord::Base self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end - def build_timeout_in_minutes - build_timeout / 60 - end - - def build_timeout_in_minutes=(value) - self.build_timeout = value.to_i * 60 - end - def open_issues_count Projects::OpenIssuesCountService.new(self).count end @@ -1463,7 +1474,9 @@ class Project < ActiveRecord::Base end def rename_repo_notify! - send_move_instructions(full_path_was) + # When we import a project overwriting the original project, there + # is a move operation. In that case we don't want to send the instructions. + send_move_instructions(full_path_was) unless started? expires_full_path_cache self.old_path_with_namespace = full_path_was @@ -1478,6 +1491,7 @@ class Project < ActiveRecord::Base remove_import_jid update_project_counter_caches after_create_default_branch + refresh_markdown_cache! end def update_project_counter_caches diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb new file mode 100644 index 00000000000..ab4482f0c0b --- /dev/null +++ b/app/models/project_deploy_token.rb @@ -0,0 +1,8 @@ +class ProjectDeployToken < ActiveRecord::Base + belongs_to :project + belongs_to :deploy_token, inverse_of: :project_deploy_tokens + + validates :deploy_token, presence: true + validates :project, presence: true + validates :deploy_token_id, uniqueness: { scope: [:project_id] } +end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index dab0ea1a681..7591ab4f478 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -21,8 +21,16 @@ class ChatNotificationService < Service end end + def confidential_issue_channel + properties['confidential_issue_channel'].presence || properties['issue_channel'] + end + + def confidential_note_channel + properties['confidential_note_channel'].presence || properties['note_channel'] + end + def self.supported_events - %w[push issue confidential_issue merge_request note tag_push + %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] end @@ -55,7 +63,9 @@ class ChatNotificationService < Service return false unless message - channel_name = get_channel_field(object_kind).presence || channel + event_type = data[:event_type] || object_kind + + channel_name = get_channel_field(event_type).presence || channel opts = {} opts[:channel] = channel_name if channel_name diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index f31c3f02af2..dce878e485f 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -46,7 +46,7 @@ class HipchatService < Service end def self.supported_events - %w(push issue confidential_issue merge_request note tag_push pipeline) + %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline) end def execute(data) diff --git a/app/models/repository.rb b/app/models/repository.rb index fd1afafe4df..5bdaa7f0720 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -331,6 +331,7 @@ class Repository return unless empty? expire_method_caches(%i(has_visible_content?)) + raw_repository.expire_has_local_branches_cache end def lookup_cache diff --git a/app/models/service.rb b/app/models/service.rb index 7424cef0fc0..f7e3f7590ad 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -14,6 +14,7 @@ class Service < ActiveRecord::Base default_value_for :merge_requests_events, true default_value_for :tag_push_events, true default_value_for :note_events, true + default_value_for :confidential_note_events, true default_value_for :job_events, true default_value_for :pipeline_events, true default_value_for :wiki_page_events, true @@ -42,6 +43,7 @@ class Service < ActiveRecord::Base scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) } scope :note_hooks, -> { where(note_events: true, active: true) } + scope :confidential_note_hooks, -> { where(confidential_note_events: true, active: true) } scope :job_hooks, -> { where(job_events: true, active: true) } scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } @@ -168,8 +170,10 @@ class Service < ActiveRecord::Base def self.prop_accessor(*args) args.each do |arg| class_eval %{ - def #{arg} - properties['#{arg}'] + unless method_defined?(arg) + def #{arg} + properties['#{arg}'] + end end def #{arg}=(value) @@ -202,7 +206,11 @@ class Service < ActiveRecord::Base args.each do |arg| class_eval %{ def #{arg}? - ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(#{arg}) + if Gitlab.rails5? + !ActiveModel::Type::Boolean::FALSE_VALUES.include?(#{arg}) + else + ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(#{arg}) + end end } end diff --git a/app/models/todo.rb b/app/models/todo.rb index a2ab405fdbe..aad2c1dac4e 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -22,7 +22,7 @@ class Todo < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :note belongs_to :project - belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations + belongs_to :target, -> { auto_include(false) }, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user delegate :name, :email, to: :author, prefix: true, allow_nil: true diff --git a/app/models/user.rb b/app/models/user.rb index ba51595e6a3..d5c5c0964c5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -96,23 +96,23 @@ class User < ActiveRecord::Base # Groups has_many :members has_many :group_members, -> { where(requested_at: nil) }, source: 'GroupMember' - has_many :groups, through: :group_members - has_many :owned_groups, -> { where members: { access_level: Gitlab::Access::OWNER } }, through: :group_members, source: :group - has_many :masters_groups, -> { where members: { access_level: Gitlab::Access::MASTER } }, through: :group_members, source: :group + has_many :groups, -> { auto_include(false) }, through: :group_members + has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }).auto_include(false) }, through: :group_members, source: :group + has_many :masters_groups, -> { where(members: { access_level: Gitlab::Access::MASTER }).auto_include(false) }, through: :group_members, source: :group # Projects - has_many :groups_projects, through: :groups, source: :projects - has_many :personal_projects, through: :namespace, source: :projects + has_many :groups_projects, -> { auto_include(false) }, through: :groups, source: :projects + has_many :personal_projects, -> { auto_include(false) }, through: :namespace, source: :projects has_many :project_members, -> { where(requested_at: nil) } - has_many :projects, through: :project_members - has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' + has_many :projects, -> { auto_include(false) }, through: :project_members + has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :starred_projects, through: :users_star_projects, source: :project + has_many :starred_projects, -> { auto_include(false) }, through: :users_star_projects, source: :project has_many :project_authorizations - has_many :authorized_projects, through: :project_authorizations, source: :project + has_many :authorized_projects, -> { auto_include(false) }, through: :project_authorizations, source: :project has_many :user_interacted_projects - has_many :project_interactions, through: :user_interacted_projects, source: :project, class_name: 'Project' + has_many :project_interactions, -> { auto_include(false) }, through: :user_interacted_projects, source: :project, class_name: 'Project' has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent @@ -132,7 +132,7 @@ class User < ActiveRecord::Base has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent has_many :issue_assignees - has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue + has_many :assigned_issues, -> { auto_include(false) }, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent has_many :custom_attributes, class_name: 'UserCustomAttribute' @@ -164,12 +164,15 @@ class User < ActiveRecord::Base before_validation :sanitize_attrs before_validation :set_notification_email, if: :email_changed? + before_save :set_notification_email, if: :email_changed? # in case validation is skipped before_validation :set_public_email, if: :public_email_changed? + before_save :set_public_email, if: :public_email_changed? # in case validation is skipped before_save :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? } before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } before_validation :ensure_namespace_correct + before_save :ensure_namespace_correct # in case validation is skipped after_validation :set_username_errors after_update :username_changed_hook, if: :username_changed? after_destroy :post_destroy_hook @@ -408,7 +411,6 @@ class User < ActiveRecord::Base unique_internal(where(ghost: true), 'ghost', email) do |u| u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.' u.name = 'Ghost User' - u.notification_email = email end end end @@ -698,10 +700,6 @@ class User < ActiveRecord::Base projects_limit - personal_projects_count end - def personal_projects_count - @personal_projects_count ||= personal_projects.count - end - def recent_push(project = nil) service = Users::LastPushEventService.new(self) @@ -995,7 +993,7 @@ class User < ActiveRecord::Base def ci_authorized_runners @ci_authorized_runners ||= begin runner_ids = Ci::RunnerProject - .where("ci_runner_projects.project_id IN (#{ci_projects_union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection + .where(project: authorized_projects(Gitlab::Access::MASTER)) .select(:runner_id) Ci::Runner.specific.where(id: runner_ids) end @@ -1044,9 +1042,10 @@ class User < ActiveRecord::Base end end - def update_cache_counts - assigned_open_merge_requests_count(force: true) - assigned_open_issues_count(force: true) + def personal_projects_count(force: false) + Rails.cache.fetch(['users', id, 'personal_projects_count'], force: force, expires_in: 24.hours, raw: true) do + personal_projects.count + end.to_i end def update_todos_count_cache @@ -1059,6 +1058,7 @@ class User < ActiveRecord::Base invalidate_merge_request_cache_counts invalidate_todos_done_count invalidate_todos_pending_count + invalidate_personal_projects_count end def invalidate_issue_cache_counts @@ -1077,6 +1077,10 @@ class User < ActiveRecord::Base Rails.cache.delete(['users', id, 'todos_pending_count']) end + def invalidate_personal_projects_count + Rails.cache.delete(['users', id, 'personal_projects_count']) + end + # This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth # flow means we don't call that automatically (and can't conveniently do so). # @@ -1200,15 +1204,6 @@ class User < ActiveRecord::Base ], remove_duplicates: false) end - def ci_projects_union - scope = { access_level: [Gitlab::Access::MASTER, Gitlab::Access::OWNER] } - groups = groups_projects.where(members: scope) - other = projects.where(members: scope) - - Gitlab::SQL::Union.new([personal_projects.select(:id), groups.select(:id), - other.select(:id)]) - end - # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration def send_devise_notification(notification, *args) return true unless can?(:receive_notifications) diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 1ab391a5a9d..808a81cbbf9 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -11,7 +11,7 @@ module Ci end condition(:owner_of_job) do - can?(:developer_access) && @subject.triggered_by?(@user) + @subject.triggered_by?(@user) end rule { protected_ref }.policy do @@ -19,6 +19,6 @@ module Ci prevent :erase_build end - rule { can?(:master_access) | owner_of_job }.enable :erase_build + rule { can?(:admin_build) | (can?(:update_build) & owner_of_job) }.enable :erase_build end end diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb index dc7a4aed577..ecba0488d3c 100644 --- a/app/policies/ci/pipeline_schedule_policy.rb +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -7,23 +7,17 @@ module Ci end condition(:owner_of_schedule) do - can?(:developer_access) && pipeline_schedule.owned_by?(@user) + pipeline_schedule.owned_by?(@user) end - condition(:non_owner_of_schedule) do - !pipeline_schedule.owned_by?(@user) - end - - rule { can?(:developer_access) }.policy do - enable :play_pipeline_schedule - end + rule { can?(:create_pipeline) }.enable :play_pipeline_schedule - rule { can?(:master_access) | owner_of_schedule }.policy do + rule { can?(:admin_pipeline) | (can?(:update_build) & owner_of_schedule) }.policy do enable :update_pipeline_schedule enable :admin_pipeline_schedule end - rule { can?(:master_access) & non_owner_of_schedule }.policy do + rule { can?(:admin_pipeline_schedule) & ~owner_of_schedule }.policy do enable :take_ownership_pipeline_schedule end diff --git a/app/policies/deploy_token_policy.rb b/app/policies/deploy_token_policy.rb new file mode 100644 index 00000000000..7aa9106e8b1 --- /dev/null +++ b/app/policies/deploy_token_policy.rb @@ -0,0 +1,11 @@ +class DeployTokenPolicy < BasePolicy + with_options scope: :subject, score: 0 + condition(:master) { @subject.project.team.master?(@user) } + + rule { anonymous }.prevent_all + + rule { master }.policy do + enable :create_deploy_token + enable :update_deploy_token + end +end diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 3f6d7d04667..b431d376e3d 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -2,20 +2,6 @@ class IssuablePolicy < BasePolicy delegate { @subject.project } condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? } - - # We aren't checking `:read_issue` or `:read_merge_request` in this case - # because it could be possible for a user to see an issuable-iid - # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be allowed - # to read the actual issue after a more expensive `:read_issue` check. - # - # `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee. - condition(:visible_to_user, score: 4) do - Project.where(id: @subject.project) - .public_or_visible_to_user(@user) - .with_feature_available_for_user(@subject, @user) - .any? - end - condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) } desc "User is the assignee or author" @@ -32,9 +18,7 @@ class IssuablePolicy < BasePolicy rule { locked & ~is_project_member }.policy do prevent :create_note - prevent :update_note prevent :admin_note prevent :resolve_note - prevent :edit_note end end diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index ed499511999..263c6e3039c 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -17,6 +17,4 @@ class IssuePolicy < IssuablePolicy prevent :update_issue prevent :admin_issue end - - rule { can?(:read_issue) | visible_to_user }.enable :read_issue_iid end diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index e003376d219..c3fe857f8a2 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -1,3 +1,2 @@ class MergeRequestPolicy < IssuablePolicy - rule { can?(:read_merge_request) | visible_to_user }.enable :read_merge_request_iid end diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index d4cb5a77e63..077a6761ee6 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -1,26 +1,21 @@ class NotePolicy < BasePolicy delegate { @subject.project } - delegate { @subject.noteable if @subject.noteable.lockable? } + delegate { @subject.noteable if DeclarativePolicy.has_policy?(@subject.noteable) } condition(:is_author) { @user && @subject.author == @user } - condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? } condition(:is_noteable_author) { @user && @subject.noteable.author_id == @user.id } condition(:editable, scope: :subject) { @subject.editable? } - rule { ~editable | anonymous }.prevent :edit_note - - rule { is_author | admin }.enable :edit_note - rule { can?(:master_access) }.enable :edit_note + rule { ~editable }.prevent :admin_note rule { is_author }.policy do enable :read_note - enable :update_note enable :admin_note enable :resolve_note end - rule { for_merge_request & is_noteable_author }.policy do + rule { is_noteable_author }.policy do enable :resolve_note end end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index cac0530b9f7..c1a84727cfa 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -25,4 +25,6 @@ class PersonalSnippetPolicy < BasePolicy end rule { anonymous }.prevent :comment_personal_snippet + + rule { can?(:comment_personal_snippet) }.enable :award_emoji end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 57ab0c23dcd..3529d0aa60c 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -1,12 +1,26 @@ class ProjectPolicy < BasePolicy - def self.create_read_update_admin(name) - [ - :"create_#{name}", - :"read_#{name}", - :"update_#{name}", - :"admin_#{name}" - ] - end + extend ClassMethods + + READONLY_FEATURES_WHEN_ARCHIVED = %i[ + issue + list + merge_request + label + milestone + project_snippet + wiki + note + pipeline + pipeline_schedule + build + trigger + environment + deployment + commit_status + container_image + pages + cluster + ].freeze desc "User is a project owner" condition :owner do @@ -15,7 +29,7 @@ class ProjectPolicy < BasePolicy end desc "Project has public builds enabled" - condition(:public_builds, scope: :subject) { project.public_builds? } + condition(:public_builds, scope: :subject, score: 0) { project.public_builds? } # For guest access we use #team_member? so we can use # project.members, which gets cached in subject scope. @@ -35,7 +49,7 @@ class ProjectPolicy < BasePolicy condition(:master) { team_access_level >= Gitlab::Access::MASTER } desc "Project is public" - condition(:public_project, scope: :subject) { project.public? } + condition(:public_project, scope: :subject, score: 0) { project.public? } desc "Project is visible to internal users" condition(:internal_access) do @@ -46,7 +60,7 @@ class ProjectPolicy < BasePolicy condition(:group_member, scope: :subject) { project_group_member? } desc "Project is archived" - condition(:archived, scope: :subject) { project.archived? } + condition(:archived, scope: :subject, score: 0) { project.archived? } condition(:default_issues_tracker, scope: :subject) { project.default_issues_tracker? } @@ -56,16 +70,32 @@ class ProjectPolicy < BasePolicy end desc "Project has an external wiki" - condition(:has_external_wiki, scope: :subject) { project.has_external_wiki? } + condition(:has_external_wiki, scope: :subject, score: 0) { project.has_external_wiki? } desc "Project has request access enabled" - condition(:request_access_enabled, scope: :subject) { project.request_access_enabled } + condition(:request_access_enabled, scope: :subject, score: 0) { project.request_access_enabled } desc "Has merge requests allowing pushes to user" condition(:has_merge_requests_allowing_pushes, scope: :subject) do project.merge_requests_allowing_push_to_user(user).any? end + # We aren't checking `:read_issue` or `:read_merge_request` in this case + # because it could be possible for a user to see an issuable-iid + # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be + # allowed to read the actual issue after a more expensive `:read_issue` + # check. These checks are intended to be used alongside + # `:read_project_for_iids`. + # + # `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee. + condition(:issues_visible_to_user, score: 4) do + @subject.feature_available?(:issues, @user) + end + + condition(:merge_requests_visible_to_user, score: 4) do + @subject.feature_available?(:merge_requests, @user) + end + features = %w[ merge_requests issues @@ -81,6 +111,10 @@ class ProjectPolicy < BasePolicy condition(:"#{f}_disabled", score: 32) { !feature_available?(f.to_sym) } end + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should + # not. + rule { guest | admin }.enable :read_project_for_iids + rule { guest }.enable :guest_access rule { reporter }.enable :reporter_access rule { developer }.enable :developer_access @@ -106,6 +140,7 @@ class ProjectPolicy < BasePolicy rule { can?(:guest_access) }.policy do enable :read_project + enable :create_merge_request_in enable :read_board enable :read_list enable :read_wiki @@ -120,10 +155,11 @@ class ProjectPolicy < BasePolicy enable :create_note enable :upload_file enable :read_cycle_analytics + enable :award_emoji end # These abilities are not allowed to admins that are not members of the project, - # that's why they are defined separatly. + # that's why they are defined separately. rule { guest & can?(:download_code) }.enable :build_download_code rule { guest & can?(:read_container_image) }.enable :build_read_container_image @@ -150,6 +186,7 @@ class ProjectPolicy < BasePolicy # where we enable or prevent it based on other coditions. rule { (~anonymous & public_project) | internal_access }.policy do enable :public_user_access + enable :read_project_for_iids end rule { can?(:public_user_access) }.policy do @@ -176,7 +213,7 @@ class ProjectPolicy < BasePolicy enable :create_pipeline enable :update_pipeline enable :create_pipeline_schedule - enable :create_merge_request + enable :create_merge_request_from enable :create_wiki enable :push_code enable :resolve_note @@ -187,7 +224,7 @@ class ProjectPolicy < BasePolicy end rule { can?(:master_access) }.policy do - enable :delete_protected_branch + enable :push_to_delete_protected_branch enable :update_project_snippet enable :update_environment enable :update_deployment @@ -210,37 +247,50 @@ class ProjectPolicy < BasePolicy end rule { archived }.policy do - prevent :create_merge_request prevent :push_code - prevent :delete_protected_branch - prevent :update_merge_request - prevent :admin_merge_request + prevent :push_to_delete_protected_branch + prevent :request_access + prevent :upload_file + prevent :resolve_note + prevent :create_merge_request_from + prevent :create_merge_request_in + prevent :award_emoji + + READONLY_FEATURES_WHEN_ARCHIVED.each do |feature| + prevent(*create_update_admin_destroy(feature)) + end + end + + rule { issues_disabled }.policy do + prevent(*create_read_update_admin_destroy(:issue)) end rule { merge_requests_disabled | repository_disabled }.policy do - prevent(*create_read_update_admin(:merge_request)) + prevent :create_merge_request_in + prevent :create_merge_request_from + prevent(*create_read_update_admin_destroy(:merge_request)) end rule { issues_disabled & merge_requests_disabled }.policy do - prevent(*create_read_update_admin(:label)) - prevent(*create_read_update_admin(:milestone)) + prevent(*create_read_update_admin_destroy(:label)) + prevent(*create_read_update_admin_destroy(:milestone)) end rule { snippets_disabled }.policy do - prevent(*create_read_update_admin(:project_snippet)) + prevent(*create_read_update_admin_destroy(:project_snippet)) end rule { wiki_disabled & ~has_external_wiki }.policy do - prevent(*create_read_update_admin(:wiki)) + prevent(*create_read_update_admin_destroy(:wiki)) prevent(:download_wiki_code) end rule { builds_disabled | repository_disabled }.policy do - prevent(*create_read_update_admin(:build)) - prevent(*(create_read_update_admin(:pipeline) - [:read_pipeline])) - prevent(*create_read_update_admin(:pipeline_schedule)) - prevent(*create_read_update_admin(:environment)) - prevent(*create_read_update_admin(:deployment)) + prevent(*create_update_admin_destroy(:pipeline)) + prevent(*create_read_update_admin_destroy(:build)) + prevent(*create_read_update_admin_destroy(:pipeline_schedule)) + prevent(*create_read_update_admin_destroy(:environment)) + prevent(*create_read_update_admin_destroy(:deployment)) end rule { repository_disabled }.policy do @@ -251,11 +301,15 @@ class ProjectPolicy < BasePolicy end rule { container_registry_disabled }.policy do - prevent(*create_read_update_admin(:container_image)) + prevent(*create_read_update_admin_destroy(:container_image)) end rule { anonymous & ~public_project }.prevent_all - rule { public_project }.enable(:public_access) + + rule { public_project }.policy do + enable :public_access + enable :read_project_for_iids + end rule { can?(:public_access) }.policy do enable :read_project @@ -289,13 +343,6 @@ class ProjectPolicy < BasePolicy enable :read_pipeline_schedule end - rule { issues_disabled }.policy do - prevent :create_issue - prevent :update_issue - prevent :admin_issue - prevent :read_issue - end - # These rules are included to allow maintainers of projects to push to certain # to run pipelines for the branches they have access to. rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do @@ -305,6 +352,14 @@ class ProjectPolicy < BasePolicy enable :update_pipeline end + rule do + (can?(:read_project_for_iids) & issues_visible_to_user) | can?(:read_issue) + end.enable :read_issue_iid + + rule do + (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request) + end.enable :read_merge_request_iid + private def team_member? diff --git a/app/policies/project_policy/class_methods.rb b/app/policies/project_policy/class_methods.rb new file mode 100644 index 00000000000..60e5aba00ba --- /dev/null +++ b/app/policies/project_policy/class_methods.rb @@ -0,0 +1,19 @@ +class ProjectPolicy + module ClassMethods + def create_read_update_admin_destroy(name) + [ + :"read_#{name}", + *create_update_admin_destroy(name) + ] + end + + def create_update_admin_destroy(name) + [ + :"create_#{name}", + :"update_#{name}", + :"admin_#{name}", + :"destroy_#{name}" + ] + end + end +end diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 255475e1fe6..9afebda19be 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -15,6 +15,8 @@ module Ci def status_title if auto_canceled? "Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" + else + tooltip_for_badge end end @@ -28,5 +30,19 @@ module Ci trigger_request.user_variables end end + + def tooltip_message + "#{subject.name} - #{detailed_status.status_tooltip}" + end + + private + + def tooltip_for_badge + detailed_status.badge_tooltip.capitalize + end + + def detailed_status + @detailed_status ||= subject.detailed_status(user) + end end end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 9f3f2637183..4b4132af2d0 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -3,6 +3,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated include GitlabRoutingHelper include MarkupHelper include TreeHelper + include ChecksCollaboration include Gitlab::Utils::StrongMemoize presents :merge_request @@ -152,11 +153,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def can_revert_on_current_merge_request? - user_can_collaborate_with_project? && cached_can_be_reverted? + can_collaborate_with_project?(project) && cached_can_be_reverted? end def can_cherry_pick_on_current_merge_request? - user_can_collaborate_with_project? && can_be_cherry_picked? + can_collaborate_with_project?(project) && can_be_cherry_picked? end def can_push_to_source_branch? @@ -195,12 +196,6 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end.sort.to_sentence end - def user_can_collaborate_with_project? - can?(current_user, :push_code, project) || - (current_user && current_user.already_forked?(project)) || - can_push_to_source_branch? - end - def user_can_fork_project? can?(current_user, :fork_project, project) end diff --git a/app/serializers/build_metadata_entity.rb b/app/serializers/build_metadata_entity.rb index 39f429aa6c3..f16f3badffa 100644 --- a/app/serializers/build_metadata_entity.rb +++ b/app/serializers/build_metadata_entity.rb @@ -1,8 +1,5 @@ class BuildMetadataEntity < Grape::Entity - expose :timeout_human_readable do |metadata| - metadata.timeout_human_readable unless metadata.timeout.nil? - end - + expose :timeout_human_readable expose :timeout_source do |metadata| metadata.present.timeout_source end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index b5e2334b6e3..840fdbcbf14 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -29,6 +29,10 @@ class IssueEntity < IssuableEntity expose :can_update do |issue| can?(request.current_user, :update_issue, issue) end + + expose :can_award_emoji do |issue| + can?(request.current_user, :award_emoji, issue) + end end expose :create_note_path do |issue| diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index c964aa9c99b..06d603b277e 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -15,7 +15,11 @@ class NoteEntity < API::Entities::Note expose :current_user do expose :can_edit do |note| - Ability.can_edit_note?(request.current_user, note) + Ability.allowed?(request.current_user, :admin_note, note) + end + + expose :can_award_emoji do |note| + Ability.allowed?(request.current_user, :award_emoji, note) end end diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index a7c2e21e92b..8e8bda2f9df 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -2,7 +2,7 @@ class StatusEntity < Grape::Entity include RequestAwareEntity expose :icon, :text, :label, :group - + expose :status_tooltip, as: :tooltip expose :has_details?, as: :has_details expose :details_path diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 2b77f6be72a..f28cddb2af3 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -109,7 +109,7 @@ module Auth case requested_action when 'pull' - build_can_pull?(requested_project) || user_can_pull?(requested_project) + build_can_pull?(requested_project) || user_can_pull?(requested_project) || deploy_token_can_pull?(requested_project) when 'push' build_can_push?(requested_project) || user_can_push?(requested_project) when '*' @@ -123,22 +123,34 @@ module Auth Gitlab.config.registry end + def can_user?(ability, project) + user = current_user.is_a?(User) ? current_user : nil + can?(user, ability, project) + end + def build_can_pull?(requested_project) # Build can: # 1. pull from its own project (for ex. a build) # 2. read images from dependent projects if creator of build is a team member has_authentication_ability?(:build_read_container_image) && - (requested_project == project || can?(current_user, :build_read_container_image, requested_project)) + (requested_project == project || can_user?(:build_read_container_image, requested_project)) end def user_can_admin?(requested_project) has_authentication_ability?(:admin_container_image) && - can?(current_user, :admin_container_image, requested_project) + can_user?(:admin_container_image, requested_project) end def user_can_pull?(requested_project) has_authentication_ability?(:read_container_image) && - can?(current_user, :read_container_image, requested_project) + can_user?(:read_container_image, requested_project) + end + + def deploy_token_can_pull?(requested_project) + has_authentication_ability?(:read_container_image) && + current_user.is_a?(DeployToken) && + current_user.has_access_to?(requested_project) && + current_user.read_registry? end ## @@ -154,7 +166,7 @@ module Auth def user_can_push?(requested_project) has_authentication_ability?(:create_container_image) && - can?(current_user, :create_container_image, requested_project) + can_user?(:create_container_image, requested_project) end def error(code, status:, message: '') diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index ecd74b74f8a..ac70a99c2c5 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -35,6 +35,7 @@ module Boards def filter_params set_parent set_state + set_scope params end @@ -51,6 +52,10 @@ module Boards params[:state] = list && list.closed? ? 'closed' : 'opened' end + def set_scope + params[:include_subgroups] = board.group_board? + end + def board_label_ids @board_label_ids ||= board.lists.movable.pluck(:label_id) end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 15fed7d17c1..3ceab209f3f 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -42,7 +42,10 @@ module Boards ) end - attrs[:move_between_ids] = move_between_ids if move_between_ids + if move_between_ids + attrs[:move_between_ids] = move_between_ids + attrs[:board_group_id] = board.group&.id + end attrs end diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index bebc90c7a8d..02f1c709374 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -12,11 +12,15 @@ module Boards private def available_labels_for(board) + options = { include_ancestor_groups: true } + if board.group_board? - parent.labels + options.merge!(group_id: parent.id, only_group_labels: true) else - LabelsFinder.new(current_user, project_id: parent.id).execute + options[:project_id] = parent.id end + + LabelsFinder.new(current_user, options).execute end def next_position(board) diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb index 15ab2d54404..84944e95542 100644 --- a/app/services/clusters/gcp/finalize_creation_service.rb +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -13,7 +13,7 @@ module Clusters rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") rescue ActiveRecord::RecordInvalid => e - provider.make_errored!("Failed to configure GKE Cluster: #{e.message}") + provider.make_errored!("Failed to configure Google Kubernetes Engine Cluster: #{e.message}") end private diff --git a/app/services/deploy_tokens/create_service.rb b/app/services/deploy_tokens/create_service.rb new file mode 100644 index 00000000000..52f545947af --- /dev/null +++ b/app/services/deploy_tokens/create_service.rb @@ -0,0 +1,7 @@ +module DeployTokens + class CreateService < BaseService + def execute + @project.deploy_tokens.create(params) + end + end +end diff --git a/app/services/events/render_service.rb b/app/services/events/render_service.rb index 0b62d8aedf1..bb72d7685dd 100644 --- a/app/services/events/render_service.rb +++ b/app/services/events/render_service.rb @@ -1,15 +1,17 @@ module Events class RenderService < BaseRenderer def execute(events, atom_request: false) - events.map(&:note).compact.group_by(&:project).each do |project, notes| - render_notes(notes, project, atom_request) - end + notes = events.map(&:note).compact + + render_notes(notes, atom_request) end private - def render_notes(notes, project, atom_request) - Notes::RenderService.new(current_user).execute(notes, project, render_options(atom_request)) + def render_notes(notes, atom_request) + Notes::RenderService + .new(current_user) + .execute(notes, render_options(atom_request)) end def render_options(atom_request) diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb index 7197a426a72..0b1a33518c6 100644 --- a/app/services/issuable/destroy_service.rb +++ b/app/services/issuable/destroy_service.rb @@ -4,6 +4,7 @@ module Issuable TodoService.new.destroy_target(issuable) do |issuable| if issuable.destroy issuable.update_project_counter_caches + issuable.assignees.each(&:invalidate_cache_counts) end end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 02fb48108fb..1f67e3ecf9d 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -51,9 +51,10 @@ class IssuableBaseService < BaseService return unless milestone_id params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE + group_ids = project.group&.self_and_ancestors&.pluck(:id) milestone = - Milestone.for_projects_and_groups([project.id], [project.group&.id]).find_by_id(milestone_id) + Milestone.for_projects_and_groups([project.id], group_ids).find_by_id(milestone_id) params[:milestone_id] = '' unless milestone end @@ -106,7 +107,7 @@ class IssuableBaseService < BaseService end def available_labels - @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute + @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: true).execute end def handle_quick_actions_on_create(issuable) diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index d7aa7e2347e..1374f10c586 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -55,9 +55,10 @@ module Issues return unless params[:move_between_ids] after_id, before_id = params.delete(:move_between_ids) + board_group_id = params.delete(:board_group_id) - issue_before = get_issue_if_allowed(issue.project, before_id) if before_id - issue_after = get_issue_if_allowed(issue.project, after_id) if after_id + issue_before = get_issue_if_allowed(before_id, board_group_id) + issue_after = get_issue_if_allowed(after_id, board_group_id) issue.move_between(issue_before, issue_after) end @@ -84,8 +85,16 @@ module Issues private - def get_issue_if_allowed(project, id) - issue = project.issues.find(id) + def get_issue_if_allowed(id, board_group_id = nil) + return unless id + + issue = + if board_group_id + IssuesFinder.new(current_user, group_id: board_group_id, include_subgroups: true).find_by(id: id) + else + project.issues.find(id) + end + issue if can?(current_user, :update_issue, issue) end diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index c57a2445341..fe1ac70781e 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -71,8 +71,8 @@ module MergeRequests params.delete(:source_project_id) params.delete(:target_project_id) - unless can?(current_user, :read_project, @source_project) && - can?(current_user, :read_project, @project) + unless can?(current_user, :create_merge_request_from, @source_project) && + can?(current_user, :create_merge_request_in, @project) raise Gitlab::Access::AccessDeniedError end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index ad3dcc5010b..199b8028dbc 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -11,7 +11,7 @@ module Notes unless @note.system? EventCreateService.new.leave_note(@note, @note.author) - return unless @note.for_project_noteable? + return if @note.for_personal_snippet? @note.create_cross_references! execute_note_hooks @@ -23,9 +23,13 @@ module Notes end def execute_note_hooks + return unless @note.project + note_data = hook_data - @note.project.execute_hooks(note_data, :note_hooks) - @note.project.execute_services(note_data, :note_hooks) + hooks_scope = @note.confidential? ? :confidential_note_hooks : :note_hooks + + @note.project.execute_hooks(note_data, hooks_scope) + @note.project.execute_services(note_data, hooks_scope) end end end diff --git a/app/services/notes/render_service.rb b/app/services/notes/render_service.rb index a77e98c2b07..efc9d6da2aa 100644 --- a/app/services/notes/render_service.rb +++ b/app/services/notes/render_service.rb @@ -3,19 +3,18 @@ module Notes # Renders a collection of Note instances. # # notes - The notes to render. - # project - The project to use for redacting. - # user - The user viewing the notes. - + # # Possible options: + # # requested_path - The request path. # project_wiki - The project's wiki. # ref - The current Git reference. # only_path - flag to turn relative paths into absolute ones. # xhtml - flag to save the html in XHTML - def execute(notes, project, **opts) - renderer = Banzai::ObjectRenderer.new(project, current_user, **opts) - - renderer.render(notes, :note) + def execute(notes, options = {}) + Banzai::ObjectRenderer + .new(user: current_user, redaction_context: options) + .render(notes, :note) end end end diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index e4be953e810..b82d9c64296 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -54,8 +54,7 @@ module NotificationRecipientService users = users.includes(:notification_settings) end - users = Array(users) - users.compact! + users = Array(users).compact recipients.concat(users.map { |u| make_recipient(u, type, reason) }) end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index e61ecb696d0..346971138b1 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -21,7 +21,8 @@ module Projects end def labels(target = nil) - labels = LabelsFinder.new(current_user, project_id: project.id).execute.select([:color, :title]) + labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true) + .execute.select([:color, :title]) return labels unless target&.respond_to?(:labels) diff --git a/app/services/projects/base_move_relations_service.rb b/app/services/projects/base_move_relations_service.rb new file mode 100644 index 00000000000..e8fd3ef57e5 --- /dev/null +++ b/app/services/projects/base_move_relations_service.rb @@ -0,0 +1,22 @@ +module Projects + class BaseMoveRelationsService < BaseService + attr_reader :source_project + def execute(source_project, remove_remaining_elements: true) + return if source_project.blank? + + @source_project = source_project + + true + end + + private + + def prepare_relation(relation, id_param = :id) + if Gitlab::Database.postgresql? + relation + else + relation.model.where("#{id_param}": relation.pluck(id_param)) + end + end + end +end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 633e2c8236c..d361d070993 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -96,6 +96,8 @@ module Projects system_hook_service.execute_hooks_for(@project, :create) setup_authorizations + + current_user.invalidate_personal_projects_count end # Refresh the current user's authorizations inline (so they can access the diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 4b8f955ae69..aa14206db3b 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -34,6 +34,8 @@ module Projects system_hook_service.execute_hooks_for(project, :destroy) log_info("Project \"#{project.full_path}\" was removed") + current_user.invalidate_personal_projects_count + true rescue => error attempt_rollback(project, error.message) @@ -44,6 +46,20 @@ module Projects raise end + def attempt_repositories_rollback + return unless @project + + flush_caches(@project) + + unless mv_repository(removal_path(repo_path), repo_path) + raise_error('Failed to restore project repository. Please contact the administrator.') + end + + unless mv_repository(removal_path(wiki_path), wiki_path) + raise_error('Failed to restore wiki repository. Please contact the administrator.') + end + end + private def repo_path @@ -68,12 +84,9 @@ module Projects # Skip repository removal. We use this flag when remove user or group return true if params[:skip_repo] == true - # There is a possibility project does not have repository or wiki - return true unless gitlab_shell.exists?(project.repository_storage_path, path + '.git') - new_path = removal_path(path) - if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path) + if mv_repository(path, new_path) log_info("Repository \"#{path}\" moved to \"#{new_path}\"") project.run_after_commit do @@ -85,6 +98,13 @@ module Projects end end + def mv_repository(from_path, to_path) + # There is a possibility project does not have repository or wiki + return true unless gitlab_shell.exists?(project.repository_storage_path, from_path + '.git') + + gitlab_shell.mv_repository(project.repository_storage_path, from_path, to_path) + end + def attempt_rollback(project, message) return unless project diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb index a68ecb4abe1..a16268f4fd2 100644 --- a/app/services/projects/gitlab_projects_import_service.rb +++ b/app/services/projects/gitlab_projects_import_service.rb @@ -5,8 +5,8 @@ module Projects class GitlabProjectsImportService attr_reader :current_user, :params - def initialize(user, params) - @current_user, @params = user, params.dup + def initialize(user, import_params, override_params = nil) + @current_user, @params, @override_params = user, import_params.dup, override_params end def execute @@ -15,8 +15,18 @@ module Projects file = params.delete(:file) FileUtils.copy_entry(file.path, import_upload_path) + @overwrite = params.delete(:overwrite) + data = {} + data[:override_params] = @override_params if @override_params + + if overwrite_project? + data[:original_path] = params[:path] + params[:path] += "-#{tmp_filename}" + end + params[:import_type] = 'gitlab_project' params[:import_source] = import_upload_path + params[:import_data] = { data: data } if data.present? ::Projects::CreateService.new(current_user, params).execute end @@ -30,5 +40,17 @@ module Projects def tmp_filename SecureRandom.hex end + + def overwrite_project? + @overwrite && project_with_same_full_path? + end + + def project_with_same_full_path? + Project.find_by_full_path("#{current_namespace.full_path}/#{params[:path]}").present? + end + + def current_namespace + @current_namespace ||= Namespace.find_by(id: params[:namespace_id]) + end end end diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index 402cddd3ec1..7bf0b90b491 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -28,7 +28,7 @@ module Projects end def save_services - [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save) + [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver].all?(&:save) end def version_saver @@ -55,6 +55,10 @@ module Projects Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: @shared) end + def lfs_saver + Gitlab::ImportExport::LfsSaver.new(project: project, shared: @shared) + end + def cleanup_and_notify_error Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{@shared.errors.join(', ')}") diff --git a/app/services/projects/move_access_service.rb b/app/services/projects/move_access_service.rb new file mode 100644 index 00000000000..3af3a22d486 --- /dev/null +++ b/app/services/projects/move_access_service.rb @@ -0,0 +1,25 @@ +module Projects + class MoveAccessService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super + + @project.with_transaction_returning_status do + if @project.namespace != source_project.namespace + @project.run_after_commit do + source_project.namespace.refresh_project_authorizations + self.namespace.refresh_project_authorizations + end + end + + ::Projects::MoveProjectMembersService.new(@project, @current_user) + .execute(source_project, remove_remaining_elements: remove_remaining_elements) + ::Projects::MoveProjectGroupLinksService.new(@project, @current_user) + .execute(source_project, remove_remaining_elements: remove_remaining_elements) + ::Projects::MoveProjectAuthorizationsService.new(@project, @current_user) + .execute(source_project, remove_remaining_elements: remove_remaining_elements) + + success + end + end + end +end diff --git a/app/services/projects/move_deploy_keys_projects_service.rb b/app/services/projects/move_deploy_keys_projects_service.rb new file mode 100644 index 00000000000..dde420655b0 --- /dev/null +++ b/app/services/projects/move_deploy_keys_projects_service.rb @@ -0,0 +1,31 @@ +module Projects + class MoveDeployKeysProjectsService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super + + Project.transaction(requires_new: true) do + move_deploy_keys_projects + remove_remaining_deploy_keys_projects if remove_remaining_elements + + success + end + end + + private + + def move_deploy_keys_projects + prepare_relation(non_existent_deploy_keys_projects) + .update_all(project_id: @project.id) + end + + def non_existent_deploy_keys_projects + source_project.deploy_keys_projects + .joins(:deploy_key) + .where.not(keys: { fingerprint: @project.deploy_keys.select(:fingerprint) }) + end + + def remove_remaining_deploy_keys_projects + source_project.deploy_keys_projects.destroy_all + end + end +end diff --git a/app/services/projects/move_forks_service.rb b/app/services/projects/move_forks_service.rb new file mode 100644 index 00000000000..d2901ea1457 --- /dev/null +++ b/app/services/projects/move_forks_service.rb @@ -0,0 +1,42 @@ +module Projects + class MoveForksService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super && source_project.fork_network + + Project.transaction(requires_new: true) do + move_forked_project_links + move_fork_network_members + update_root_project + refresh_forks_count + + success + end + end + + private + + def move_forked_project_links + # Update ancestor + ForkedProjectLink.where(forked_to_project: source_project) + .update_all(forked_to_project_id: @project.id) + + # Update the descendants + ForkedProjectLink.where(forked_from_project: source_project) + .update_all(forked_from_project_id: @project.id) + end + + def move_fork_network_members + ForkNetworkMember.where(project: source_project).update_all(project_id: @project.id) + ForkNetworkMember.where(forked_from_project: source_project).update_all(forked_from_project_id: @project.id) + end + + def update_root_project + # Update root network project + ForkNetwork.where(root_project: source_project).update_all(root_project_id: @project.id) + end + + def refresh_forks_count + Projects::ForksCountService.new(@project).refresh_cache + end + end +end diff --git a/app/services/projects/move_lfs_objects_projects_service.rb b/app/services/projects/move_lfs_objects_projects_service.rb new file mode 100644 index 00000000000..298da5f1a82 --- /dev/null +++ b/app/services/projects/move_lfs_objects_projects_service.rb @@ -0,0 +1,29 @@ +module Projects + class MoveLfsObjectsProjectsService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super + + Project.transaction(requires_new: true) do + move_lfs_objects_projects + remove_remaining_lfs_objects_project if remove_remaining_elements + + success + end + end + + private + + def move_lfs_objects_projects + prepare_relation(non_existent_lfs_objects_projects) + .update_all(project_id: @project.lfs_storage_project.id) + end + + def remove_remaining_lfs_objects_project + source_project.lfs_objects_projects.destroy_all + end + + def non_existent_lfs_objects_projects + source_project.lfs_objects_projects.where.not(lfs_object: @project.lfs_objects) + end + end +end diff --git a/app/services/projects/move_notification_settings_service.rb b/app/services/projects/move_notification_settings_service.rb new file mode 100644 index 00000000000..f7be461a5da --- /dev/null +++ b/app/services/projects/move_notification_settings_service.rb @@ -0,0 +1,38 @@ +module Projects + class MoveNotificationSettingsService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super + + Project.transaction(requires_new: true) do + move_notification_settings + remove_remaining_notification_settings if remove_remaining_elements + + success + end + end + + private + + def move_notification_settings + prepare_relation(non_existent_notifications) + .update_all(source_id: @project.id) + end + + # Remove remaining notification settings from source_project + def remove_remaining_notification_settings + source_project.notification_settings.destroy_all + end + + # Get users of current notification_settings + def users_in_target_project + @project.notification_settings.select(:user_id) + end + + # Look for notification_settings in source_project that are not in the target project + def non_existent_notifications + source_project.notification_settings + .select(:id) + .where.not(user_id: users_in_target_project) + end + end +end diff --git a/app/services/projects/move_project_authorizations_service.rb b/app/services/projects/move_project_authorizations_service.rb new file mode 100644 index 00000000000..5ef12fc49e5 --- /dev/null +++ b/app/services/projects/move_project_authorizations_service.rb @@ -0,0 +1,40 @@ +# NOTE: This service cannot be used directly because it is part of a +# a bigger process. Instead, use the service MoveAccessService which moves +# project memberships, project group links, authorizations and refreshes +# the authorizations if neccessary +module Projects + class MoveProjectAuthorizationsService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super + + Project.transaction(requires_new: true) do + move_project_authorizations + + remove_remaining_authorizations if remove_remaining_elements + + success + end + end + + private + + def move_project_authorizations + prepare_relation(non_existent_authorization, :user_id) + .update_all(project_id: @project.id) + end + + def remove_remaining_authorizations + # I think because the Project Authorization table does not have a primary key + # it brings a lot a problems/bugs. First, Rails raises PG::SyntaxException if we use + # destroy_all instead of delete_all. + source_project.project_authorizations.delete_all(:delete_all) + end + + # Look for authorizations in source_project that are not in the target project + def non_existent_authorization + source_project.project_authorizations + .select(:user_id) + .where.not(user: @project.authorized_users) + end + end +end diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb new file mode 100644 index 00000000000..dbeffd7dae9 --- /dev/null +++ b/app/services/projects/move_project_group_links_service.rb @@ -0,0 +1,40 @@ +# NOTE: This service cannot be used directly because it is part of a +# a bigger process. Instead, use the service MoveAccessService which moves +# project memberships, project group links, authorizations and refreshes +# the authorizations if neccessary +module Projects + class MoveProjectGroupLinksService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super + + Project.transaction(requires_new: true) do + move_group_links + remove_remaining_project_group_links if remove_remaining_elements + + success + end + end + + private + + def move_group_links + prepare_relation(non_existent_group_links) + .update_all(project_id: @project.id) + end + + # Remove remaining project group links from source_project + def remove_remaining_project_group_links + source_project.reload.project_group_links.destroy_all + end + + def group_links_in_target_project + @project.project_group_links.select(:group_id) + end + + # Look for groups in source_project that are not in the target project + def non_existent_group_links + source_project.project_group_links + .where.not(group_id: group_links_in_target_project) + end + end +end diff --git a/app/services/projects/move_project_members_service.rb b/app/services/projects/move_project_members_service.rb new file mode 100644 index 00000000000..22a5f0a3fe6 --- /dev/null +++ b/app/services/projects/move_project_members_service.rb @@ -0,0 +1,40 @@ +# NOTE: This service cannot be used directly because it is part of a +# a bigger process. Instead, use the service MoveAccessService which moves +# project memberships, project group links, authorizations and refreshes +# the authorizations if neccessary +module Projects + class MoveProjectMembersService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super + + Project.transaction(requires_new: true) do + move_project_members + remove_remaining_members if remove_remaining_elements + + success + end + end + + private + + def move_project_members + prepare_relation(non_existent_members).update_all(source_id: @project.id) + end + + def remove_remaining_members + # Remove remaining members and authorizations from source_project + source_project.project_members.destroy_all + end + + def project_members_in_target_project + @project.project_members.select(:user_id) + end + + # Look for members in source_project that are not in the target project + def non_existent_members + source_project.members + .select(:id) + .where.not(user_id: @project.project_members.select(:user_id)) + end + end +end diff --git a/app/services/projects/move_users_star_projects_service.rb b/app/services/projects/move_users_star_projects_service.rb new file mode 100644 index 00000000000..079fd5b9685 --- /dev/null +++ b/app/services/projects/move_users_star_projects_service.rb @@ -0,0 +1,20 @@ +module Projects + class MoveUsersStarProjectsService < BaseMoveRelationsService + def execute(source_project, remove_remaining_elements: true) + return unless super + + user_stars = source_project.users_star_projects + + return unless user_stars.any? + + Project.transaction(requires_new: true) do + user_stars.update_all(project_id: @project.id) + + Project.reset_counters @project.id, :users_star_projects + Project.reset_counters source_project.id, :users_star_projects + + success + end + end + end +end diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb new file mode 100644 index 00000000000..ce94f147aa9 --- /dev/null +++ b/app/services/projects/overwrite_project_service.rb @@ -0,0 +1,69 @@ +module Projects + class OverwriteProjectService < BaseService + def execute(source_project) + return unless source_project && source_project.namespace == @project.namespace + + Project.transaction do + move_before_destroy_relationships(source_project) + destroy_old_project(source_project) + rename_project(source_project.name, source_project.path) + + @project + end + # Projects::DestroyService can raise Exceptions, but we don't want + # to pass that kind of exception to the caller. Instead, we change it + # for a StandardError exception + rescue Exception => e # rubocop:disable Lint/RescueException + attempt_restore_repositories(source_project) + + if e.class == Exception + raise StandardError, e.message + else + raise + end + end + + private + + def move_before_destroy_relationships(source_project) + options = { remove_remaining_elements: false } + + ::Projects::MoveUsersStarProjectsService.new(@project, @current_user).execute(source_project, options) + ::Projects::MoveAccessService.new(@project, @current_user).execute(source_project, options) + ::Projects::MoveDeployKeysProjectsService.new(@project, @current_user).execute(source_project, options) + ::Projects::MoveNotificationSettingsService.new(@project, @current_user).execute(source_project, options) + ::Projects::MoveForksService.new(@project, @current_user).execute(source_project, options) + ::Projects::MoveLfsObjectsProjectsService.new(@project, @current_user).execute(source_project, options) + add_source_project_to_fork_network(source_project) + end + + def destroy_old_project(source_project) + # Delete previous project (synchronously) and unlink relations + ::Projects::DestroyService.new(source_project, @current_user).execute + end + + def rename_project(name, path) + # Update de project's name and path to the original name/path + ::Projects::UpdateService.new(@project, + @current_user, + { name: name, path: path }) + .execute + end + + def attempt_restore_repositories(project) + ::Projects::DestroyService.new(project, @current_user).attempt_repositories_rollback + end + + def add_source_project_to_fork_network(source_project) + return unless @project.fork_network + + # Because he have moved all references in the fork network from the source_project + # we won't be able to query the database (only through its cached data), + # for its former relationships. That's why we're adding it to the network + # as a fork of the target project + ForkNetworkMember.create!(fork_network: @project.fork_network, + project: source_project, + forked_from_project: @project) + end + end +end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 26765e5c3f3..5a23f0f0a62 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -24,6 +24,8 @@ module Projects transfer(project) + current_user.invalidate_personal_projects_count + true rescue Projects::TransferService::TransferError => ex project.reload diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 7e228d1833d..de77f6bf585 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -74,25 +74,13 @@ module Projects end def extract_archive!(temp_path) - if artifacts.ends_with?('.tar.gz') || artifacts.ends_with?('.tgz') - extract_tar_archive!(temp_path) - elsif artifacts.ends_with?('.zip') + if artifacts.ends_with?('.zip') extract_zip_archive!(temp_path) else raise InvaildStateError, 'unsupported artifacts format' end end - def extract_tar_archive!(temp_path) - build.artifacts_file.use_file do |artifacts_path| - results = Open3.pipeline(%W(gunzip -c #{artifacts_path}), - %W(dd bs=#{BLOCK_SIZE} count=#{blocks}), - %W(tar -x -C #{temp_path} #{SITE_PATH}), - err: '/dev/null') - raise FailedToExtractError, 'pages failed to extract' unless results.compact.all?(&:success?) - end - end - def extract_zip_archive!(temp_path) raise InvaildStateError, 'missing artifacts metadata' unless build.artifacts_metadata? diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index cba49faac31..6cc51b6ee1b 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -200,7 +200,7 @@ module QuickActions end params '~label1 ~"label 2"' condition do - available_labels = LabelsFinder.new(current_user, project_id: project.id).execute + available_labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true).execute current_user.can?(:"admin_#{issuable.to_ability_name}", project) && available_labels.any? @@ -562,7 +562,7 @@ module QuickActions def find_labels(labels_param) extract_references(labels_param, :label) | - LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute + LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split, include_ancestor_groups: true).execute end def find_label_references(labels_param) @@ -593,6 +593,7 @@ module QuickActions def extract_references(arg, type) ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(arg, author: current_user) ext.references(type) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 2253d638e93..00bf5434b7f 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -429,7 +429,7 @@ module SystemNoteService def cross_reference(noteable, mentioner, author) return if cross_reference_disallowed?(noteable, mentioner) - gfm_reference = mentioner.gfm_reference(noteable.project) + gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) body = cross_reference_note_content(gfm_reference) if noteable.is_a?(ExternalIssue) @@ -582,7 +582,7 @@ module SystemNoteService text = "#{cross_reference_note_prefix}%#{mentioner.to_reference(nil)}" notes.where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) else - gfm_reference = mentioner.gfm_reference(noteable.project) + gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) text = cross_reference_note_content(gfm_reference) notes.where(note: [text, text.capitalize]) end diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb index ef0f8acefd6..dd86753479d 100644 --- a/app/uploaders/job_artifact_uploader.rb +++ b/app/uploaders/job_artifact_uploader.rb @@ -2,6 +2,8 @@ class JobArtifactUploader < GitlabUploader extend Workhorse::UploadPath include ObjectStorage::Concern + ObjectNotReadyError = Class.new(StandardError) + storage_options Gitlab.config.artifacts def size @@ -25,6 +27,8 @@ class JobArtifactUploader < GitlabUploader private def dynamic_segment + raise ObjectNotReadyError, 'JobArtifact is not ready' unless model.id + creation_date = model.created_at.utc.strftime('%Y_%m_%d') File.join(disk_hash[0..1], disk_hash[2..3], disk_hash, diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb index b726b053493..efb7893d153 100644 --- a/app/uploaders/legacy_artifact_uploader.rb +++ b/app/uploaders/legacy_artifact_uploader.rb @@ -2,6 +2,8 @@ class LegacyArtifactUploader < GitlabUploader extend Workhorse::UploadPath include ObjectStorage::Concern + ObjectNotReadyError = Class.new(StandardError) + storage_options Gitlab.config.artifacts def store_dir @@ -11,6 +13,8 @@ class LegacyArtifactUploader < GitlabUploader private def dynamic_segment + raise ObjectNotReadyError, 'Build is not ready' unless model.id + File.join(model.created_at.utc.strftime('%Y_%m'), model.project_id.to_s, model.id.to_s) end end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 4028b052768..bd258e04d3f 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -128,7 +128,7 @@ module ObjectStorage end def direct_upload_enabled? - object_store_options.direct_upload + object_store_options&.direct_upload end def background_upload_enabled? @@ -156,11 +156,10 @@ module ObjectStorage end def workhorse_authorize - if options = workhorse_remote_upload_options - { RemoteObject: options } - else - { TempPath: workhorse_local_upload_path } - end + { + RemoteObject: workhorse_remote_upload_options, + TempPath: workhorse_local_upload_path + }.compact end def workhorse_local_upload_path @@ -184,6 +183,14 @@ module ObjectStorage StoreURL: connection.put_object_url(remote_store_path, upload_path, expire_at, options) } end + + def default_object_store + if self.object_store_enabled? && self.direct_upload_enabled? + Store::REMOTE + else + Store::LOCAL + end + end end # allow to configure and overwrite the filename @@ -204,12 +211,12 @@ module ObjectStorage end def object_store - @object_store ||= model.try(store_serialization_column) || Store::LOCAL + @object_store ||= model.try(store_serialization_column) || self.class.default_object_store end # rubocop:disable Gitlab/ModuleWithInstanceVariables def object_store=(value) - @object_store = value || Store::LOCAL + @object_store = value || self.class.default_object_store @storage = storage_for(object_store) end # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -285,16 +292,14 @@ module ObjectStorage } end - def store_workhorse_file!(params, identifier) - filename = params["#{identifier}.name"] - - if remote_object_id = params["#{identifier}.remote_id"] - store_remote_file!(remote_object_id, filename) - elsif local_path = params["#{identifier}.path"] - store_local_file!(local_path, filename) - else - raise RemoteStoreError, 'Bad file' + def cache!(new_file = sanitized_file) + # We intercept ::UploadedFile which might be stored on remote storage + # We use that for "accelerated" uploads, where we store result on remote storage + if new_file.is_a?(::UploadedFile) && new_file.remote_id + return cache_remote_file!(new_file.remote_id, new_file.original_filename) end + + super end private @@ -305,36 +310,29 @@ module ObjectStorage self.file_storage? end - def store_remote_file!(remote_object_id, filename) - raise RemoteStoreError, 'Missing filename' unless filename - + def cache_remote_file!(remote_object_id, original_filename) file_path = File.join(TMP_UPLOAD_PATH, remote_object_id) file_path = Pathname.new(file_path).cleanpath.to_s raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(TMP_UPLOAD_PATH + '/') - self.object_store = Store::REMOTE - # TODO: # This should be changed to make use of `tmp/cache` mechanism # instead of using custom upload directory, # using tmp/cache makes this implementation way easier than it is today - CarrierWave::Storage::Fog::File.new(self, storage, file_path).tap do |file| + CarrierWave::Storage::Fog::File.new(self, storage_for(Store::REMOTE), file_path).tap do |file| raise RemoteStoreError, 'Missing file' unless file.exists? - self.filename = filename - self.file = storage.store!(file) - end - end - - def store_local_file!(local_path, filename) - raise RemoteStoreError, 'Missing filename' unless filename + # Remote stored file, we force to store on remote storage + self.object_store = Store::REMOTE - root_path = File.realpath(self.class.workhorse_local_upload_path) - file_path = File.realpath(local_path) - raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(root_path) - - self.object_store = Store::LOCAL - self.store!(UploadedFile.new(file_path, filename)) + # TODO: + # We store file internally and force it to be considered as `cached` + # This makes CarrierWave to store file in permament location (copy/delete) + # once this object is saved, but not sooner + @cache_id = "force-to-use-cache" # rubocop:disable Gitlab/ModuleWithInstanceVariables + @file = file # rubocop:disable Gitlab/ModuleWithInstanceVariables + @filename = original_filename # rubocop:disable Gitlab/ModuleWithInstanceVariables + end end # this is a hack around CarrierWave. The #migrate method needs to be diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml new file mode 100644 index 00000000000..6c89f1c4e98 --- /dev/null +++ b/app/views/admin/application_settings/_email.html.haml @@ -0,0 +1,26 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :email_author_in_body do + = f.check_box :email_author_in_body + Include author name in notification email body + .help-block + Some email servers do not support overriding the email sender name. + Enable this option to include the name of the author of the issue, + merge request or comment in the email body instead. + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :html_emails_enabled do + = f.check_box :html_emails_enabled + Enable HTML emails + .help-block + By default GitLab sends emails in HTML and plain text formats so mail + clients can choose what format to use. Disable this option if you only + want to send emails in plain text format. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml deleted file mode 100644 index 9ab2c2892b2..00000000000 --- a/app/views/admin/application_settings/_form.html.haml +++ /dev/null @@ -1,173 +0,0 @@ -= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| - = form_errors(@application_setting) - - - if Gitlab.config.registry.enabled - %fieldset - %legend Container Registry - .form-group - = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :container_registry_token_expire_delay, class: 'form-control' - - - if koding_enabled? - %fieldset - %legend Koding - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :koding_enabled do - = f.check_box :koding_enabled - Enable Koding - .help-block - Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again. - .form-group - = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' - .help-block - Koding has integration enabled out of the box for the - %strong gitlab - team, and you need to provide that team's URL here. Learn more in the - = succeed "." do - = link_to "Koding administration documentation", help_page_path("administration/integration/koding") - - %fieldset - %legend PlantUML - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :plantuml_enabled do - = f.check_box :plantuml_enabled - Enable PlantUML - .form-group - = f.label :plantuml_url, 'PlantUML URL', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' - .help-block - Allow rendering of - = link_to "PlantUML", "http://plantuml.com" - diagrams in Asciidoc documents using an external PlantUML service. - - %fieldset - %legend#usage-statistics Usage statistics - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :version_check_enabled do - = f.check_box :version_check_enabled - Enable version check - .help-block - GitLab will inform you if a new version is available. - = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") - about what information is shared with GitLab Inc. - .form-group - .col-sm-offset-2.col-sm-10 - - can_be_configured = @application_setting.usage_ping_can_be_configured? - .checkbox - = f.label :usage_ping_enabled do - = f.check_box :usage_ping_enabled, disabled: !can_be_configured - Enable usage ping - .help-block - - if can_be_configured - To help improve GitLab and its user experience, GitLab will - periodically collect usage information. - = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping") - about what information is shared with GitLab Inc. Visit - = link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping') - to see the JSON payload sent. - - else - The usage ping is disabled, and cannot be configured through this - form. For more information, see the documentation on - = succeed '.' do - = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') - - %fieldset - %legend Email - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :email_author_in_body do - = f.check_box :email_author_in_body - Include author name in notification email body - .help-block - Some email servers do not support overriding the email sender name. - Enable this option to include the name of the author of the issue, - merge request or comment in the email body instead. - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :html_emails_enabled do - = f.check_box :html_emails_enabled - Enable HTML emails - .help-block - By default GitLab sends emails in HTML and plain text formats so mail - clients can choose what format to use. Disable this option if you only - want to send emails in plain text format. - - %fieldset - %legend Gitaly Timeouts - .form-group - = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_default, class: 'form-control' - .help-block - Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced - for git fetch/push operations or Sidekiq jobs. - .form-group - = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_fast, class: 'form-control' - .help-block - Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. - If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' - can help maintain the stability of the GitLab instance. - .form-group - = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_medium, class: 'form-control' - .help-block - Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. - - %fieldset - %legend Web terminal - .form-group - = f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :terminal_max_session_time, class: 'form-control' - .help-block - Maximum time for web terminal websocket connection (in seconds). - 0 for unlimited. - - %fieldset - %legend Real-time features - .form-group - = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :polling_interval_multiplier, class: 'form-control' - .help-block - Change this value to influence how frequently the GitLab UI polls for updates. - If you set the value to 2 all polling intervals are multiplied - by 2, which means that polling happens half as frequently. - The multiplier can also have a decimal value. - The default value (1) is a reasonable choice for the majority of GitLab - installations. Set to 0 to completely disable polling. - = link_to icon('question-circle'), help_page_path('administration/polling') - - %fieldset - %legend Performance optimization - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :authorized_keys_enabled do - = f.check_box :authorized_keys_enabled - Write to "authorized_keys" file - .help-block - By default, we write to the "authorized_keys" file to support Git - over SSH without additional configuration. GitLab can be optimized - to authenticate SSH keys via the database file. Only uncheck this - if you have configured your OpenSSH server to use the - AuthorizedKeysCommand. Click on the help icon for more details. - = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup') - - .form-actions - = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml new file mode 100644 index 00000000000..4acc5b3a0c5 --- /dev/null +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -0,0 +1,27 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_default, class: 'form-control' + .help-block + Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced + for git fetch/push operations or Sidekiq jobs. + .form-group + = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_fast, class: 'form-control' + .help-block + Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. + If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' + can help maintain the stability of the GitLab instance. + .form-group + = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_medium, class: 'form-control' + .help-block + Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_koding.html.haml b/app/views/admin/application_settings/_koding.html.haml new file mode 100644 index 00000000000..17358cf775b --- /dev/null +++ b/app/views/admin/application_settings/_koding.html.haml @@ -0,0 +1,24 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :koding_enabled do + = f.check_box :koding_enabled + Enable Koding + .help-block + Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again. + .form-group + = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' + .help-block + Koding has integration enabled out of the box for the + %strong gitlab + team, and you need to provide that team's URL here. Learn more in the + = succeed "." do + = link_to "Koding administration documentation", help_page_path("administration/integration/koding") + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml new file mode 100644 index 00000000000..01d5a31aa9f --- /dev/null +++ b/app/views/admin/application_settings/_performance.html.haml @@ -0,0 +1,19 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :authorized_keys_enabled do + = f.check_box :authorized_keys_enabled + Write to "authorized_keys" file + .help-block + By default, we write to the "authorized_keys" file to support Git + over SSH without additional configuration. GitLab can be optimized + to authenticate SSH keys via the database file. Only uncheck this + if you have configured your OpenSSH server to use the + AuthorizedKeysCommand. Click on the help icon for more details. + = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup') + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml new file mode 100644 index 00000000000..56764b3fb81 --- /dev/null +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -0,0 +1,20 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :plantuml_enabled do + = f.check_box :plantuml_enabled + Enable PlantUML + .form-group + = f.label :plantuml_url, 'PlantUML URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' + .help-block + Allow rendering of + = link_to "PlantUML", "http://plantuml.com" + diagrams in Asciidoc documents using an external PlantUML service. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml new file mode 100644 index 00000000000..0a53a75119e --- /dev/null +++ b/app/views/admin/application_settings/_realtime.html.haml @@ -0,0 +1,19 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :polling_interval_multiplier, class: 'form-control' + .help-block + Change this value to influence how frequently the GitLab UI polls for updates. + If you set the value to 2 all polling intervals are multiplied + by 2, which means that polling happens half as frequently. + The multiplier can also have a decimal value. + The default value (1) is a reasonable choice for the majority of GitLab + installations. Set to 0 to completely disable polling. + = link_to icon('question-circle'), help_page_path('administration/polling') + + = f.submit 'Save changes', class: "btn btn-success" + diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml new file mode 100644 index 00000000000..3451ef62458 --- /dev/null +++ b/app/views/admin/application_settings/_registry.html.haml @@ -0,0 +1,10 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :container_registry_token_expire_delay, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 864e64b5fa9..48331c40bca 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -24,6 +24,7 @@ - if omniauth_enabled? && button_based_providers.any? .form-group = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2' + = hidden_field_tag 'application_setting[enabled_oauth_sign_in_sources][]' .col-sm-10 .btn-group{ data: { toggle: 'buttons' } } - oauth_providers_checkboxes.each do |source| diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml new file mode 100644 index 00000000000..36d8838803f --- /dev/null +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -0,0 +1,13 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :terminal_max_session_time, class: 'form-control' + .help-block + Maximum time for web terminal websocket connection (in seconds). + 0 for unlimited. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml new file mode 100644 index 00000000000..7684e2cfdd1 --- /dev/null +++ b/app/views/admin/application_settings/_usage.html.haml @@ -0,0 +1,37 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :version_check_enabled do + = f.check_box :version_check_enabled + Enable version check + .help-block + GitLab will inform you if a new version is available. + = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") + about what information is shared with GitLab Inc. + .form-group + .col-sm-offset-2.col-sm-10 + - can_be_configured = @application_setting.usage_ping_can_be_configured? + .checkbox + = f.label :usage_ping_enabled do + = f.check_box :usage_ping_enabled, disabled: !can_be_configured + Enable usage ping + .help-block + - if can_be_configured + To help improve GitLab and its user experience, GitLab will + periodically collect usage information. + = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping") + about what information is shared with GitLab Inc. Visit + = link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping') + to see the JSON payload sent. + - else + The usage ping is disabled, and cannot be configured through this + form. For more information, see the documentation on + = succeed '.' do + = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') + + = f.submit 'Save changes', class: "btn btn-success" + diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index cbc779548f6..a75dd90fe6b 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -32,6 +32,7 @@ .form-group = f.label :import_sources, class: 'control-label col-sm-2' .col-sm-10 + = hidden_field_tag 'application_setting[import_sources][]' - import_sources_checkboxes('import-sources-help').each do |source| .checkbox= source %span.help-block#import-sources-help diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index f4320513aff..caaa93aa1e2 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -76,7 +76,7 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p - = _('Auto DevOps, runners amd job artifacts') + = _('Auto DevOps, runners and job artifacts') .settings-content = render 'ci_cd' @@ -102,7 +102,7 @@ .settings-content = render 'prometheus' -%section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded) } +%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded) } .settings-header %h4 = _('Profiling - Performance bar') @@ -180,6 +180,107 @@ .settings-content = render 'repository_check' +- if Gitlab.config.registry.enabled + %section.settings.as-registry.no-animate#js-registry-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Container Registry') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Various container registry settings.') + .settings-content + = render 'registry' + +- if koding_enabled? + %section.settings.as-koding.no-animate#js-koding-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Koding') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Online IDE integration settings.') + .settings-content + = render 'koding' + +%section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('PlantUML') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Allow rendering of PlantUML diagrams in Asciidoc documents.') + .settings-content + = render 'plantuml' + +%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded) } + .settings-header#usage-statistics + %h4 + = _('Usage statistics') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable or disable version check and usage ping.') + .settings-content + = render 'usage' + +%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Email') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Various email settings.') + .settings-content + = render 'email' + +%section.settings.as-gitaly.no-animate#js-gitaly-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Gitaly') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure Gitaly timeouts.') + .settings-content + = render 'gitaly' + +%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Web terminal') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Set max session time for web terminal.') + .settings-content + = render 'terminal' + +%section.settings.as-realtime.no-animate#js-realtime-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Real-time features') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Change this value to influence how frequently the GitLab UI polls for updates.') + .settings-content + = render 'realtime' + +%section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Performance optimization') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Various settings that affect GitLab performance.') + .settings-content + = render 'performance' + %section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded) } .settings-header %h4 @@ -201,6 +302,3 @@ = _('Allow requests to the local network from hooks and services.') .settings-content = render 'outbound' - -.prepend-top-20 - = render 'form' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 05c41082882..bbf0e0fb95c 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -126,6 +126,7 @@ GitLab %span.pull-right = Gitlab::VERSION + = "(#{Gitlab::REVISION})" %p GitLab Shell %span.pull-right diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index c47b8a88f56..aeba9788fda 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -101,7 +101,7 @@ - if @project.archived? %li %span.light archived: - %strong repository is read-only + %strong project is read-only %li %span.light access: diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index bbfeceff5b9..2ff4221efbd 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -33,7 +33,7 @@ = link_to 'Block', block_admin_user_path(user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put - if user.access_locked? %li - = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } + = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } - if can?(current_user, :destroy_user, user) %li.divider - if user.can_be_removed? diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 5f07d2720c2..4b3c52af16a 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -3,13 +3,13 @@ .awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } } - awards_sort(grouped_emojis).each do |emoji, awards| %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button", - class: [(award_state_class(awards, current_user)), (award_user_authored_class(emoji) if user_authored)], + class: [(award_state_class(awardable, awards, current_user)), (award_user_authored_class(emoji) if user_authored)], data: { placement: "bottom", title: award_user_list(awards, current_user) } } = emoji_icon(emoji) %span.award-control-text.js-counter = awards.count - - if current_user + - if can?(current_user, :award_emoji, awardable) .award-menu-holder.js-award-holder %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', 'aria-label': 'Add reaction', diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml index 35a3563dff1..5114387984b 100644 --- a/app/views/ci/status/_badge.html.haml +++ b/app/views/ci/status/_badge.html.haml @@ -4,10 +4,10 @@ - css_classes = "ci-status ci-#{status.group} #{'has-tooltip' if title.present?}" - if link && status.has_details? - = link_to status.details_path, class: css_classes, title: title do + = link_to status.details_path, class: css_classes, title: title, data: { html: title.present? } do = sprite_icon(status.icon) = status.text - else - %span{ class: css_classes, title: title } + %span{ class: css_classes, title: title, data: { html: title.present? } } = sprite_icon(status.icon) = status.text diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index c5b4439e273..db2040110fa 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -3,14 +3,15 @@ - subject = local_assigns.fetch(:subject) - status = subject.detailed_status(current_user) - klass = "ci-status-icon ci-status-icon-#{status.group}" -- tooltip = "#{subject.name} - #{status.label}" +- tooltip = "#{subject.name} - #{status.status_tooltip}" - if status.has_details? - = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do + = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, html: true, container: 'body' } do %span{ class: klass }= sprite_icon(status.icon) %span.ci-build-text= subject.name + - else - .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } } + .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', html: true, title: tooltip, container: 'body' } } %span{ class: klass }= sprite_icon(status.icon) %span.ci-build-text= subject.name diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index 3e85535dae0..bb472b4c900 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -1,15 +1,19 @@ - @hide_top_links = true -- page_title "Issues" -- header_title "Issues", issues_dashboard_path(assignee_id: current_user.id) +- page_title _("Issues") +- @breadcrumb_link = issues_dashboard_path(assignee_id: current_user.id) = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{current_user.name} issues") .top-area - = render 'shared/issuable/nav', type: :issues + = render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set .nav-controls = link_to params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe' do = icon('rss') = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues = render 'shared/issuable/filter', type: :issues -= render 'shared/issues' + +- if current_user && @no_filters_set + = render 'shared/dashboard/no_filter_selected' +- else + = render 'shared/issues' diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 53cd1130299..61aae31be60 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -1,11 +1,15 @@ - @hide_top_links = true -- page_title "Merge Requests" -- header_title "Merge Requests", merge_requests_dashboard_path(assignee_id: current_user.id) +- page_title _("Merge Requests") +- @breadcrumb_link = merge_requests_dashboard_path(assignee_id: current_user.id) .top-area - = render 'shared/issuable/nav', type: :merge_requests + = render 'shared/issuable/nav', type: :merge_requests, display_count: !@no_filters_set .nav-controls = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests', type: :merge_requests = render 'shared/issuable/filter', type: :merge_requests -= render 'shared/merge_requests' + +- if current_user && @no_filters_set + = render 'shared/dashboard/no_filter_selected' +- else + = render 'shared/merge_requests' diff --git a/app/views/devise/shared/_tab_single.html.haml b/app/views/devise/shared/_tab_single.html.haml index f943d25e41a..7bd414d64c3 100644 --- a/app/views/devise/shared/_tab_single.html.haml +++ b/app/views/devise/shared/_tab_single.html.haml @@ -1,3 +1,3 @@ -%ul.nav-links.nav-tabs.new-session-tabs.single-tab +%ul.nav-links.new-session-tabs.single-tab %li.active %a= tab_title diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index 270191f9452..f50e0724e09 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -1,4 +1,4 @@ -%ul.new-session-tabs.nav-links.nav-tabs{ class: ('custom-provider-tabs' if form_based_providers.any?) } +%ul.nav-links.new-session-tabs{ class: ('custom-provider-tabs' if form_based_providers.any?) } - if crowd_enabled? %li.active = link_to "Crowd", "#crowd", 'data-toggle' => 'tab' diff --git a/app/views/devise/shared/_tabs_normal.html.haml b/app/views/devise/shared/_tabs_normal.html.haml index 1ba6d390875..fa3c3df7f60 100644 --- a/app/views/devise/shared/_tabs_normal.html.haml +++ b/app/views/devise/shared/_tabs_normal.html.haml @@ -1,4 +1,4 @@ -%ul.nav-links.new-session-tabs.nav-tabs{ role: 'tablist' } +%ul.nav-links.new-session-tabs{ role: 'tablist' } %li.active{ role: 'presentation' } %a{ href: '#login-pane', data: { toggle: 'tab' }, role: 'tab' } Sign in - if allow_signup? diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 8680ec2e298..646e89e9bd1 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -7,7 +7,7 @@ - unless expanded - diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) } -.diff-file.file-holder{ class: diff_file_class, data: diff_data } +.diff-file.file-holder.js-lazy-load-discussion{ class: diff_file_class, data: diff_data } .js-file-title.file-title.file-title-flex-parent .file-header-content = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false @@ -28,8 +28,11 @@ %tr.line_holder.line-holder-placeholder %td.old_line.diff-line-num %td.new_line.diff-line-num - %td.line_content + %td.line_content.js-success-lazy-load .js-code-placeholder + %td.js-error-lazy-load-diff.hidden.diff-loading-error-block + - button = button_tag(_("Try again"), class: "btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button") + = _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button} = render "discussions/diff_discussion", discussions: [discussion], expanded: true - else - partial = (diff_file.new_file? || diff_file.deleted_file?) ? 'single_image_diff' : 'replaced_image_diff' diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml index 6693e6f90e8..af518b5b583 100644 --- a/app/views/email_rejection_mailer/rejection.text.haml +++ b/app/views/email_rejection_mailer/rejection.text.haml @@ -1,4 +1,3 @@ Unfortunately, your email message to GitLab could not be processed. - - +\ = @reason diff --git a/app/views/groups/settings/badges/index.html.haml b/app/views/groups/settings/badges/index.html.haml new file mode 100644 index 00000000000..c7afb25d0f8 --- /dev/null +++ b/app/views/groups/settings/badges/index.html.haml @@ -0,0 +1,4 @@ +- breadcrumb_title _('Project Badges') +- page_title _('Project Badges') + += render 'shared/badges/badge_settings' diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index eb32f393310..6f53f5ac1ae 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -19,8 +19,8 @@ %li.dropdown-bold-header GitLab - if @project&.persisted? - - create_project_issue = can?(current_user, :create_issue, @project) - - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) + - create_project_issue = show_new_issue_link?(@project) + - merge_project = merge_request_source_project_for_project(@project) - create_project_snippet = can?(current_user, :create_project_snippet, @project) - if create_project_issue || merge_project || create_project_snippet %li.dropdown-bold-header This project diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 5ea19c9882d..517d9aa3d99 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -112,7 +112,7 @@ %span.nav-item-name Settings %ul.sidebar-sub-level-items - = nav_link(path: %w[groups#projects groups#edit ci_cd#show], html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show], html_options: { class: "fly-out-top-item" } ) do = link_to edit_group_path(@group) do %strong.fly-out-top-item-name #{ _('Settings') } @@ -122,6 +122,12 @@ %span General + = nav_link(controller: :badges) do + = link_to group_settings_badges_path(@group), title: _('Project Badges') do + %span + = _('Project Badges') + + = nav_link(path: 'groups#projects') do = link_to projects_group_path(@group), title: 'Projects' do %span diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 5c90d13420f..196db08cebd 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -13,7 +13,7 @@ .nav-icon-container = sprite_icon('project') %span.nav-item-name - Overview + Project %ul.sidebar-sub-level-items = nav_link(path: 'projects#show', html_options: { class: "fly-out-top-item" } ) do @@ -258,7 +258,7 @@ #{ _('Snippets') } - if project_nav_tab? :settings - = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show]) do + = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show]) do = link_to edit_project_path(@project), class: 'shortcuts-tree' do .nav-icon-container = sprite_icon('settings') @@ -268,7 +268,7 @@ %ul.sidebar-sub-level-items - can_edit = can?(current_user, :admin_project, @project) - if can_edit - = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show], html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show], html_options: { class: "fly-out-top-item" } ) do = link_to edit_project_path(@project) do %strong.fly-out-top-item-name #{ _('Settings') } @@ -282,6 +282,11 @@ %span Members - if can_edit + = nav_link(controller: :badges) do + = link_to project_settings_badges_path(@project), title: _('Badges') do + %span + = _('Badges') + - if can_edit = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do = link_to project_settings_integrations_path(@project), title: 'Integrations' do %span diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml index 4c507c08ed7..67744ec1cee 100644 --- a/app/views/notify/push_to_merge_request_email.html.haml +++ b/app/views/notify/push_to_merge_request_email.html.haml @@ -7,7 +7,7 @@ - count = @existing_commits.size %ul %li - - if count.one? + - if count == 1 - commit_id = @existing_commits.first[:short_id] = link_to(commit_id, project_commit_url(@merge_request.target_project, commit_id)) - else diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml index 553f771f1a6..95759d127e2 100644 --- a/app/views/notify/push_to_merge_request_email.text.haml +++ b/app/views/notify/push_to_merge_request_email.text.haml @@ -4,7 +4,7 @@ \ - if @existing_commits.any? - count = @existing_commits.size - - commits_id = count.one? ? @existing_commits.first[:short_id] : "#{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}" + - commits_id = count == 1 ? @existing_commits.first[:short_id] : "#{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}" - commits_text = "#{count} commit".pluralize(count) * #{commits_id} - #{commits_text} from branch `#{@merge_request.target_branch}` diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 02263095599..9c95b6281ba 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -57,20 +57,8 @@ = succeed '.' do = link_to 'Learn more', help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank' .col-lg-8 - = form_for @user, url: update_username_profile_path, method: :put, html: {class: "update-username"} do |f| - .form-group - = f.label :username, "Path", class: "label-light" - .input-group - .input-group-addon - = root_url - = f.text_field :username, required: true, class: 'form-control' - .help-block - Current path: - #{root_url}#{current_user.username} - .prepend-top-default - = f.button class: "btn btn-warning", type: "submit" do - = icon "spinner spin", class: "hidden loading-username" - Update username + - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) } + #update-username{ data: data } %hr .row.prepend-top-default diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 78848542810..9b87a7aaca8 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -2,7 +2,6 @@ - page_title "Personal Access Tokens" - @content_class = "limit-container-width" unless fluid_layout - .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 @@ -19,8 +18,10 @@ %h5.prepend-top-0 Your New Personal Access Token .form-group - = text_field_tag 'created-personal-access-token', @new_personal_access_token, readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-personal-access-token-help-block" - = clipboard_button(text: @new_personal_access_token, title: "Copy personal access token to clipboard", placement: "left") + .input-group + = text_field_tag 'created-personal-access-token', @new_personal_access_token, readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-personal-access-token-help-block" + %span.input-group-btn + = clipboard_button(text: @new_personal_access_token, title: "Copy personal access token to clipboard", placement: "left", class: "btn-default btn-clipboard") %span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again. %hr diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 1bd10018b40..d1eae05c46c 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -20,7 +20,7 @@ - else %p Download the Google Authenticator application from App Store or Google Play Store and scan this code. - More information is available in the #{link_to('documentation', help_page_path('profile/two_factor_authentication'))}. + More information is available in the #{link_to('documentation', help_page_path('user/profile/account/two_factor_authentication'))}. .row.append-bottom-10 .col-md-4 = raw @qr_code diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 825bfd0707f..1e7d9444986 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -21,11 +21,11 @@ %li Project uploads %li Project configuration including web hooks and services %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities + %li LFS objects %p The following items will NOT be exported: %ul %li Job traces and artifacts - %li LFS objects %li Container registry images %li CI variables %li Any encrypted tokens diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index a2ecfddb163..043057e79ee 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -23,11 +23,14 @@ - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') = deleted_message % { project_name: fork_source_name(@project) } - .project-badges + .project-badges.prepend-top-default.append-bottom-default - @project.badges.each do |badge| - - badge_link_url = badge.rendered_link_url(@project) - %a{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer' } - %img{ src: badge.rendered_image_url(@project), alt: badge_link_url } + %a.append-right-8{ href: badge.rendered_link_url(@project), + target: '_blank', + rel: 'noopener noreferrer' }> + %img.project-badge{ src: badge.rendered_image_url(@project), + 'aria-hidden': true, + alt: '' }> .project-repo-buttons .count-buttons diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 6a1035d2dc7..f6d396c8127 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -13,6 +13,7 @@ #{time_ago_with_tooltip(event.created_at)} - .flex-right - = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn btn-info btn-sm qa-create-merge-request" do - #{ _('Create merge request') } + - if can?(current_user, :create_merge_request_in, event.project.default_merge_request_target) + .flex-right + = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn btn-info btn-sm qa-create-merge-request" do + #{ _('Create merge request') } diff --git a/app/views/projects/_visibility_select.html.haml b/app/views/projects/_visibility_select.html.haml deleted file mode 100644 index 4026b9e3c46..00000000000 --- a/app/views/projects/_visibility_select.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- if can_change_visibility_level?(@project, current_user) - .select-wrapper - = form.select(model_method, visibility_select_options(@project, selected_level), {}, class: 'form-control visibility-select select-control') - = icon('chevron-down') -- else - .info.js-locked{ data: { help_block: visibility_level_description(@project.visibility_level, @project) } } - = visibility_level_icon(@project.visibility_level) - %strong - = visibility_level_label(@project.visibility_level) diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index 3124443b4e4..9c760c81527 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -2,6 +2,7 @@ - render_error = viewer.render_error - rich_type = viewer.type == :rich ? viewer.partial_name : nil - load_async = local_assigns.fetch(:load_async, viewer.load_async? && render_error.nil?) +- external_embed = local_assigns.fetch(:external_embed, false) - viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async .blob-viewer{ data: { type: viewer.type, rich_type: rich_type, url: viewer_url }, class: ('hidden' if hidden) } @@ -9,6 +10,8 @@ = render 'projects/blob/render_error', viewer: viewer - elsif load_async = render viewer.loading_partial_path, viewer: viewer + - elsif external_embed + = render 'projects/blob/viewers/highlight_embed', blob: viewer.blob - else - viewer.prepare! diff --git a/app/views/projects/blob/viewers/_highlight_embed.html.haml b/app/views/projects/blob/viewers/_highlight_embed.html.haml new file mode 100644 index 00000000000..9bd4ef6ad0b --- /dev/null +++ b/app/views/projects/blob/viewers/_highlight_embed.html.haml @@ -0,0 +1,7 @@ +.file-content.code.js-syntax-highlight + .line-numbers + - if blob.data.present? + - blob.data.each_line.each_with_index do |_, index| + %span.diff-line-num= index + 1 + .blob-content{ data: { blob_id: blob.id } } + = highlight(blob.path, blob.data, repository: nil, plain: blob.no_highlighting?) diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 883dfb3e6c8..71176acd12d 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -4,7 +4,7 @@ - diverging_commit_counts = @repository.diverging_commit_counts(branch) - number_commits_behind = diverging_commit_counts[:behind] - number_commits_ahead = diverging_commit_counts[:ahead] -- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) +- merge_project = merge_request_source_project_for_project(@project) %li{ class: "branch-item js-branch-#{branch.name}" } .branch-info .branch-title @@ -61,7 +61,7 @@ title: s_('Branches|The default branch cannot be deleted') } = icon("trash-o") - elsif protected_branch?(@project, branch) - - if can?(current_user, :delete_protected_branch, @project) + - if can?(current_user, :push_to_delete_protected_branch, @project) %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", title: s_('Branches|Delete protected branch'), data: { toggle: "modal", diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index fa9a9bfc8f7..f49f6e630d2 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -1,6 +1,7 @@ - pipeline = local_assigns.fetch(:pipeline) { project.latest_successful_pipeline_for(ref) } - if !project.empty_repo? && can?(current_user, :download_code, project) + - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" .project-action-button.dropdown.inline> %button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') } = sprite_icon('download') @@ -10,16 +11,16 @@ %li.dropdown-header #{ _('Source code') } %li - = link_to archive_project_repository_path(project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do + = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do %span= _('Download zip') %li - = link_to archive_project_repository_path(project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do + = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do %span= _('Download tar.gz') %li - = link_to archive_project_repository_path(project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do + = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do %span= _('Download tar.bz2') %li - = link_to archive_project_repository_path(project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do + = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do %span= _('Download tar') - if pipeline && pipeline.latest_builds_with_artifacts.any? diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 18e948ce35a..2e86a7d36d7 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -1,13 +1,17 @@ -- if current_user +- can_create_issue = show_new_issue_link?(@project) +- can_create_project_snippet = can?(current_user, :create_project_snippet, @project) +- can_push_code = can?(current_user, :push_code, @project) +- create_mr_from_new_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) +- merge_project = merge_request_source_project_for_project(@project) + +- show_menu = can_create_issue || can_create_project_snippet || can_push_code || create_mr_from_new_fork || merge_project + +- if show_menu .project-action-button.dropdown.inline %a.btn.dropdown-toggle.has-tooltip{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...') } = icon('plus') = icon("caret-down") %ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown - - can_create_issue = can?(current_user, :create_issue, @project) - - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) - - can_create_project_snippet = can?(current_user, :create_project_snippet, @project) - - if can_create_issue || merge_project || can_create_project_snippet %li.dropdown-header= _('This project') @@ -20,17 +24,17 @@ - if can_create_project_snippet %li= link_to _('New snippet'), new_project_snippet_path(@project) - - if can?(current_user, :push_code, @project) + - if can_push_code %li.dropdown-header= _('This repository') - - if can?(current_user, :push_code, @project) + - if can_push_code %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master') - unless @project.empty_repo? %li= link_to _('New branch'), new_project_branch_path(@project) %li= link_to _('New tag'), new_project_tag_path(@project) - - elsif current_user && current_user.already_forked?(@project) + - elsif can_collaborate_with_project?(@project) %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master') - - elsif can?(current_user, :fork_project, @project) + - elsif create_mr_from_new_fork - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'), notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } diff --git a/app/views/projects/clusters/_empty_state.html.haml b/app/views/projects/clusters/_empty_state.html.haml index 112dde66ff7..5f49d03b1bb 100644 --- a/app/views/projects/clusters/_empty_state.html.haml +++ b/app/views/projects/clusters/_empty_state.html.haml @@ -7,5 +7,6 @@ - link_to_help_page = link_to(_('Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') %p= s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page} - .text-center - = link_to s_('ClusterIntegration|Add Kubernetes cluster'), new_project_cluster_path(@project), class: 'btn btn-success' + - if can?(current_user, :create_cluster, @project) + .text-center + = link_to s_('ClusterIntegration|Add Kubernetes cluster'), new_project_cluster_path(@project), class: 'btn btn-success' diff --git a/app/views/projects/clusters/new.html.haml b/app/views/projects/clusters/new.html.haml index ebb7d247125..e004966bdcc 100644 --- a/app/views/projects/clusters/new.html.haml +++ b/app/views/projects/clusters/new.html.haml @@ -8,6 +8,6 @@ %h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up Kubernetes cluster integration') %p= s_('ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab') - = link_to s_('ClusterIntegration|Create on GKE'), gcp_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20' + = link_to s_('ClusterIntegration|Create on Google Kubernetes Engine'), gcp_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20' %p= s_('ClusterIntegration|Enter the details for an existing Kubernetes cluster') = link_to s_('ClusterIntegration|Add an existing Kubernetes cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20' diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 461129a3e0e..213c4c90a0e 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,3 +1,5 @@ +- can_collaborate = can_collaborate_with_project?(@project) + .page-content-header.js-commit-box{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) } .header-main-content = render partial: 'signature', object: @commit.signature @@ -32,12 +34,13 @@ %li.visible-xs-block.visible-sm-block = link_to project_tree_path(@project, @commit) do #{ _('Browse Files') } - - unless @commit.has_been_reverted?(current_user) + - if can_collaborate && !@commit.has_been_reverted?(current_user) %li.clearfix = revert_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false) - %li.clearfix - = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false) - - if can_collaborate_with_project? + - if can_collaborate + %li.clearfix + = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false) + - if can?(current_user, :push_code, @project) %li.clearfix = link_to s_("CreateTag|Tag"), new_project_tag_path(@project, ref: @commit) %li.divider @@ -49,10 +52,10 @@ .commit-box{ data: { project_path: project_path(@project) } } %h3.commit-title - = markdown(@commit.title, pipeline: :single_line, author: @commit.author) + = markdown_field(@commit, :title) - if @commit.description.present? %pre.commit-description - = preserve(markdown(@commit.description, pipeline: :single_line, author: @commit.author)) + = preserve(markdown_field(@commit, :description)) .info-well .well-segment.branch-info diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index abb292f8f27..541ae905246 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -17,6 +17,6 @@ .limited-width-notes = render "shared/notes/notes_with_form", :autocomplete => true - - if can_collaborate_with_project? + - if can_collaborate_with_project?(@project) - %w(revert cherry-pick).each do |type| = render "projects/commit/change", type: type, commit: @commit, title: @commit.title diff --git a/app/views/projects/commits/_commit.atom.builder b/app/views/projects/commits/_commit.atom.builder index 50f7e7a3a33..640b5ecf99e 100644 --- a/app/views/projects/commits/_commit.atom.builder +++ b/app/views/projects/commits/_commit.atom.builder @@ -10,5 +10,5 @@ xml.entry do xml.email commit.author_email end - xml.summary markdown(commit.description, pipeline: :single_line), type: 'html' + xml.summary markdown_field(commit, :description), type: 'html' end diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 078bd0eee63..289bfdd69bc 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -5,6 +5,7 @@ - link = commit_path(project, commit, merge_request: merge_request) - cache_key = [project.full_path, + ref, commit.id, Gitlab::CurrentSettings.current_application_settings, @path.presence, @@ -22,7 +23,10 @@ .commit-detail.flex-list .commit-content - = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") + - if view_details && merge_request + = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title" + - else + = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") %span.commit-row-message.visible-xs-inline · = commit.short_id @@ -51,10 +55,10 @@ - if commit.status(ref) = render_commit_status(commit, ref: ref) - .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id) } } - = link_to commit.short_id, link, class: "commit-sha btn btn-transparent btn-link" - = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard")) - = link_to_browse_code(project, commit) + .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } - - if view_details && merge_request - = link_to "View details", project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "btn btn-default" + .commit-sha-group + .label.label-monospace + = commit.short_id + = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body") + = link_to_browse_code(project, commit) diff --git a/app/views/projects/deploy_tokens/_form.html.haml b/app/views/projects/deploy_tokens/_form.html.haml new file mode 100644 index 00000000000..f8db30df7b4 --- /dev/null +++ b/app/views/projects/deploy_tokens/_form.html.haml @@ -0,0 +1,29 @@ +%p.profile-settings-content + = s_("DeployTokens|Pick a name for the application, and we'll give you a unique deploy token.") + += form_for token, url: create_deploy_token_namespace_project_settings_repository_path(project.namespace, project), method: :post do |f| + = form_errors(token) + + .form-group + = f.label :name, class: 'label-light' + = f.text_field :name, class: 'form-control', required: true + + .form-group + = f.label :expires_at, class: 'label-light' + = f.text_field :expires_at, class: 'datepicker form-control', value: f.object.expires_at + + .form-group + = f.label :scopes, class: 'label-light' + %fieldset + = f.check_box :read_repository + = label_tag ("deploy_token_read_repository"), 'read_repository' + %span= s_('DeployTokens|Allows read-only access to the repository') + + - if container_registry_enabled?(project) + %fieldset + = f.check_box :read_registry + = label_tag ("deploy_token_read_registry"), 'read_registry' + %span= s_('DeployTokens|Allows read-only access to the registry images') + + .prepend-top-default + = f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success' diff --git a/app/views/projects/deploy_tokens/_index.html.haml b/app/views/projects/deploy_tokens/_index.html.haml new file mode 100644 index 00000000000..50e5950ced4 --- /dev/null +++ b/app/views/projects/deploy_tokens/_index.html.haml @@ -0,0 +1,18 @@ +- expanded = expand_deploy_tokens_section?(@new_deploy_token) + +%section.settings.no-animate{ class: ('expanded' if expanded) } + .settings-header + %h4= s_('DeployTokens|Deploy Tokens') + %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = s_('DeployTokens|Deploy tokens allow read-only access to your repository and registry images.') + .settings-content + - if @new_deploy_token.persisted? + = render 'projects/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token + - else + %h5.prepend-top-0 + = s_('DeployTokens|Add a deploy token') + = render 'projects/deploy_tokens/form', project: @project, token: @new_deploy_token, presenter: @deploy_tokens + %hr + = render 'projects/deploy_tokens/table', project: @project, active_tokens: @deploy_tokens diff --git a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml new file mode 100644 index 00000000000..1e715681e59 --- /dev/null +++ b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml @@ -0,0 +1,14 @@ +.created-deploy-token-container + %h5.prepend-top-0 + = s_('DeployTokens|Your New Deploy Token') + + .form-group + = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus' + = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username to clipboard'), placement: 'left') + %span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.") + + .form-group + = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus' + = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token to clipboard'), placement: 'left') + %span.deploy-token-help-block.prepend-top-5.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.") +%hr diff --git a/app/views/projects/deploy_tokens/_revoke_modal.html.haml b/app/views/projects/deploy_tokens/_revoke_modal.html.haml new file mode 100644 index 00000000000..085964fe22e --- /dev/null +++ b/app/views/projects/deploy_tokens/_revoke_modal.html.haml @@ -0,0 +1,17 @@ +.modal{ id: "revoke-modal-#{token.id}" } + .modal-dialog + .modal-content + .modal-header + %h4.modal-title.pull-left + = s_('DeployTokens|Revoke') + %b #{token.name}? + %button.close{ 'aria-label' => _('Close'), 'data-dismiss' => 'modal', type: 'button' } + %span{ 'aria-hidden' => 'true' } × + .modal-body + %p + = s_('DeployTokens|You are about to revoke') + %b #{token.name}. + = s_('DeployTokens|This action cannot be undone.') + .modal-footer + %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }= _('Cancel') + = link_to s_('DeployTokens|Revoke %{name}') % { name: token.name }, revoke_project_deploy_token_path(project, token), method: :put, class: 'btn btn-danger' diff --git a/app/views/projects/deploy_tokens/_table.html.haml b/app/views/projects/deploy_tokens/_table.html.haml new file mode 100644 index 00000000000..5013a9b250d --- /dev/null +++ b/app/views/projects/deploy_tokens/_table.html.haml @@ -0,0 +1,31 @@ +%h5= s_("DeployTokens|Active Deploy Tokens (%{active_tokens})") % { active_tokens: active_tokens.length } + +- if active_tokens.present? + .table-responsive.deploy-tokens + %table.table + %thead + %tr + %th= s_('DeployTokens|Name') + %th= s_('DeployTokens|Username') + %th= s_('DeployTokens|Created') + %th= s_('DeployTokens|Expires') + %th= s_('DeployTokens|Scopes') + %th + %tbody + - active_tokens.each do |token| + %tr + %td= token.name + %td= token.username + %td= token.created_at.to_date.to_s(:medium) + %td + - if token.expires? + %span{ class: ('text-warning' if token.expires_soon?) } + In #{distance_of_time_in_words_to_now(token.expires_at)} + - else + %span.token-never-expires-label Never + %td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>" + %td= link_to s_('DeployTokens|Revoke'), "#", class: "btn btn-danger pull-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"} + = render 'projects/deploy_tokens/revoke_modal', token: token, project: project +- else + .settings-message.text-center + = s_('DeployTokens|This project has no active Deploy Tokens.') diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 99eeb9551e3..0994498c6be 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -114,17 +114,18 @@ Archive project - if @project.archived? %p - Unarchiving the project will mark its repository as active. The project can be committed to. + Unarchiving the project will restore people's ability to make changes to it. + The repository can be committed to, and issues, comments and other entities can be created. %strong Once active this project shows up in the search and on the dashboard. = link_to 'Unarchive project', unarchive_project_path(@project), - data: { confirm: "Are you sure that you want to unarchive this project?\nWhen this project is unarchived it is active and can be committed to again." }, + data: { confirm: "Are you sure that you want to unarchive this project?" }, method: :post, class: "btn btn-success" - else %p - Archiving the project will mark its repository as read-only. It is hidden from the dashboard and doesn't show up in searches. - %strong Archived projects cannot be committed to! + Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. + %strong The repository cannot be committed to, and no issues, comments or other entities can be created. = link_to 'Archive project', archive_project_path(@project), - data: { confirm: "Are you sure that you want to archive this project?\nAn archived project cannot be committed to." }, + data: { confirm: "Are you sure that you want to archive this project?" }, method: :post, class: "btn btn-warning" .sub-section.rename-respository %h4.warning-title diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index cdfc3e232c5..816f2fa816d 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -8,4 +8,5 @@ %section.js-vue-notes-event #js-vue-notes{ data: { notes_data: notes_data(@issue), noteable_data: serialize_issuable(@issue), + noteable_type: 'issue', current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index 0d39edb7bfd..dd1a836fa20 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -2,9 +2,10 @@ = icon('rss') - if @can_bulk_update = button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle" -= link_to "New issue", new_project_issue_path(@project, - issue: { assignee_id: finder.assignee.try(:id), - milestone_id: finder.milestones.first.try(:id) }), - class: "btn btn-new", - title: "New issue", - id: "new_issue_link" +- if show_new_issue_link?(@project) + = link_to "New issue", new_project_issue_path(@project, + issue: { assignee_id: finder.assignee.try(:id), + milestone_id: finder.milestones.first.try(:id) }), + class: "btn btn-new", + title: "New issue", + id: "new_issue_link" diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 36e24037214..4b8bf578b28 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,8 +1,8 @@ -- can_create_merge_request = can?(current_user, :create_merge_request, @project) -- data_action = can_create_merge_request ? 'create-mr' : 'create-branch' -- value = can_create_merge_request ? 'Create merge request' : 'Create branch' - - if can?(current_user, :push_code, @project) + - can_create_merge_request = can?(current_user, :create_merge_request_in, @project) + - data_action = can_create_merge_request ? 'create-mr' : 'create-branch' + - value = can_create_merge_request ? 'Create merge request' : 'Create branch' + - can_create_path = can_create_branch_project_issue_path(@project, @issue) - create_mr_path = create_merge_request_project_issue_path(@project, @issue, branch_name: @issue.to_branch_name, ref: @project.default_branch) - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid) diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index ec7e87219f5..f1fc1c2316d 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -7,6 +7,7 @@ - can_update_issue = can?(current_user, :update_issue, @issue) - can_report_spam = @issue.submittable_as_spam_by?(current_user) +- can_create_issue = show_new_issue_link?(@project) .detail-page-header .detail-page-header-body @@ -42,16 +43,18 @@ %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - if can_report_spam %li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' - - if can_update_issue || can_report_spam - %li.divider - %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link' + - if can_create_issue + - if can_update_issue || can_report_spam + %li.divider + %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link' = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue - if can_report_spam = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' - = link_to new_project_issue_path(@project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do - New issue + - if can_create_issue + = link_to new_project_issue_path(@project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do + New issue .issue-details.issuable-details .detail-page-description.content-block diff --git a/app/views/projects/jobs/_empty_state.html.haml b/app/views/projects/jobs/_empty_state.html.haml index c66313bdbf3..311934d9c33 100644 --- a/app/views/projects/jobs/_empty_state.html.haml +++ b/app/views/projects/jobs/_empty_state.html.haml @@ -1,7 +1,7 @@ - illustration = local_assigns.fetch(:illustration) - illustration_size = local_assigns.fetch(:illustration_size) - title = local_assigns.fetch(:title) -- content = local_assigns.fetch(:content) +- content = local_assigns.fetch(:content, nil) - action = local_assigns.fetch(:action, nil) .row.empty-state @@ -11,7 +11,8 @@ .col-xs-12 .text-content %h4.text-center= title - %p= content + - if content + %p= content - if action .text-center = action diff --git a/app/views/projects/jobs/_empty_states.html.haml b/app/views/projects/jobs/_empty_states.html.haml new file mode 100644 index 00000000000..e5198d047df --- /dev/null +++ b/app/views/projects/jobs/_empty_states.html.haml @@ -0,0 +1,9 @@ +- detailed_status = @build.detailed_status(current_user) +- illustration = detailed_status.illustration + += render 'empty_state', + illustration: illustration[:image], + illustration_size: illustration[:size], + title: illustration[:title], + content: illustration[:content], + action: detailed_status.has_action? ? link_to(detailed_status.action_button_title, detailed_status.action_path, method: detailed_status.action_method, class: 'btn btn-primary', title: detailed_status.action_button_title) : nil diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index ecf186e3dc8..0b57ebedebd 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -1,5 +1,3 @@ -- builds = @build.pipeline.builds.to_a - %aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } .sidebar-container .blocks-container @@ -91,7 +89,8 @@ - HasStatus::ORDERED_STATUSES.each do |build_status| - builds.select{|build| build.status == build_status}.each do |build| .build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } } - = link_to project_job_path(@project, build) do + - tooltip = build.tooltip_message + = link_to(project_job_path(@project, build), data: { toggle: 'tooltip', html: true, title: tooltip, container: 'body' }) do = sprite_icon('arrow-right', size:16, css_class: 'icon-arrow-right') %span{ class: "ci-status-icon-#{build.status}" } = ci_icon_for_status(build.status) @@ -101,5 +100,4 @@ - else = build.id - if build.retried? - %span.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } - = sprite_icon('retry', size:16, css_class: 'icon-retry') + = sprite_icon('retry', size:16, css_class: 'icon-retry') diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index fa27ded7cc2..cbbcc8f1db5 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -54,7 +54,8 @@ Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)} - else Job has been erased #{time_ago_with_tooltip(@build.erased_at)} - - if @build.started? + + - if @build.running? || @build.has_trace? .build-trace-container.prepend-top-default .top-bar.js-top-bar .js-truncated-info.truncated-info.hidden-xs.pull-left.hidden< @@ -88,26 +89,10 @@ %pre.build-trace#build-trace %code.bash.js-build-output .build-loader-animation.js-build-refresh - - elsif @build.playable? - = render 'empty_state', - illustration: 'illustrations/manual_action.svg', - illustration_size: 'svg-394', - title: _('This job requires a manual action'), - content: _('This job depends on a user to trigger its process. Often they are used to deploy code to production environments'), - action: ( link_to _('Trigger this manual action'), play_project_job_path(@project, @build), method: :post, class: 'btn btn-primary', title: _('Trigger this manual action') ) - - elsif @build.created? - = render 'empty_state', - illustration: 'illustrations/job_not_triggered.svg', - illustration_size: 'svg-306', - title: _('This job has not been triggered yet'), - content: _('This job depends on upstream jobs that need to succeed in order for this job to be triggered') - else - = render 'empty_state', - illustration: 'illustrations/pending_job_empty.svg', - illustration_size: 'svg-430', - title: _('This job has not started yet'), - content: _('This job is in pending state and is waiting to be picked by a runner') - = render "sidebar" + = render "empty_states" + + = render "sidebar", builds: @builds .js-build-options{ data: javascript_build_options } diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index b2c0d9e1cfa..623380c9c61 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -1,6 +1,6 @@ - @no_container = true - @can_bulk_update = can?(current_user, :admin_merge_request, @project) -- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) +- merge_project = merge_request_source_project_for_project(@project) - new_merge_request_path = project_new_merge_request_path(merge_project) if merge_project - page_title "Merge Requests" diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 9866cc716ee..15a0e4d7ef5 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -80,6 +80,7 @@ - if has_vue_discussions_cookie? #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request), noteable_data: serialize_issuable(@merge_request), + noteable_type: 'merge_request', current_user_data: UserSerializer.new.represent(current_user).to_json} } #commits.commits.tab-pane diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 5ea653ccad5..b4fe1cabdfd 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -36,7 +36,7 @@ %template{ 'v-else' => '' } = render 'shared/icons/icon_resolve_discussion.svg' -- if current_user +- if can?(current_user, :award_emoji, note) - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) .note-actions-item diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index 75df92b05a7..27bbe52a714 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -1,28 +1,29 @@ +- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? + - if can?(current_user, :update_pages, @project) && @domains.any? .panel.panel-default .panel-heading Domains (#{@domains.count}) - %ul.well-list - - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? + %ul.well-list.pages-domain-list{ class: ("has-verification-status" if verification_enabled) } - @domains.each do |domain| - %li - .pull-right + %li.pages-domain-list-item.unstyled + - if verification_enabled + - tooltip, status = domain.unverified? ? [_('Unverified'), 'failed'] : [_('Verified'), 'success'] + .domain-status.ci-status-icon.has-tooltip{ class: "ci-status-icon-#{status}", title: tooltip } + = sprite_icon("status_#{status}", size: 16 ) + .domain-name + = link_to domain.url do + = domain.url + = icon('external-link') + - if domain.subject + %p + %span.label.label-gray Certificate: #{domain.subject} + - if domain.expired? + %span.label.label-danger Expired + %div = link_to 'Details', project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped" = link_to 'Remove', project_pages_domain_path(@project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" - .clearfix - - if verification_enabled - - tooltip, status = domain.unverified? ? ['Unverified', 'failed'] : ['Verified', 'success'] - = link_to domain.url, title: tooltip, class: 'has-tooltip' do - = sprite_icon("status_#{status}", size: 16, css_class: "has-tooltip ci-status-icon ci-status-icon-#{status}") - = domain.domain - - else - = link_to domain.domain, domain.url - %p - - if domain.subject - %span.label.label-gray Certificate: #{domain.subject} - - if domain.expired? - %span.label.label-danger Expired - if verification_enabled && domain.unverified? %li.warning-row #{domain.domain} is not verified. To learn how to verify ownership, visit your - = link_to 'domain details', project_pages_domain_path(@project, domain) + #{link_to 'domain details', project_pages_domain_path(@project, domain)}. diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index f17d9d24db6..6adaea799b2 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -1,11 +1,10 @@ - page_title 'Pages' -%h3.page_title +%h3.page-title.with-button Pages - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) = link_to new_project_pages_domain_path(@project), class: 'btn btn-new pull-right', title: 'New Domain' do - %i.fa.fa-plus New Domain %p.light diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml index 5645a4604bf..6c404990492 100644 --- a/app/views/projects/pages_domains/edit.html.haml +++ b/app/views/projects/pages_domains/edit.html.haml @@ -1,7 +1,7 @@ - add_to_breadcrumbs "Pages", project_pages_path(@project) - breadcrumb_title @domain.domain - page_title @domain.domain -%h3.page_title +%h3.page-title = @domain.domain %hr.clearfix %div diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index e49163880c7..269df803a2b 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -1,6 +1,6 @@ - add_to_breadcrumbs "Pages", project_pages_path(@project) - page_title 'New Pages Domain' -%h3.page_title +%h3.page-title New Pages Domain %hr.clearfix %div diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index ba0713daee9..44d66f3b2d0 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -1,17 +1,19 @@ - add_to_breadcrumbs "Pages", project_pages_path(@project) - breadcrumb_title @domain.domain - page_title "#{@domain.domain}", 'Pages Domains' +- dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}." - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? + - if verification_enabled && @domain.unverified? - %p.alert.alert-warning - %strong - This domain is not verified. You will need to verify ownership before - access is enabled. + = content_for :flash_message do + .alert.alert-warning + .container-fluid.container-limited + This domain is not verified. You will need to verify ownership before access is enabled. -%h3.page-title - Pages Domain +%h3.page-title.with-button = link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success pull-right' + Pages Domain .table-holder %table.table @@ -19,31 +21,41 @@ %td Domain %td - = link_to @domain.domain, @domain.url + = link_to @domain.url do + = @domain.url + = icon('external-link') %tr %td DNS %td - %p - To access this domain create a new DNS record: - %pre - #{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}. + .input-group + = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true + .input-group-btn + = clipboard_button(target: '#domain_dns', class: 'btn-default hidden-xs') + %p.help-block + To access this domain create a new DNS record + - if verification_enabled + - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}" %tr %td Verification status %td - %p + = form_tag verify_project_pages_domain_path(@project, @domain) do + .status-badge + - text, status = @domain.unverified? ? [_('Unverified'), 'label-danger'] : [_('Verified'), 'label-success'] + .label{ class: status } + = text + %button.btn.has-tooltip{ type: "submit", data: { container: 'body' }, title: _("Retry verification") } + = sprite_icon('redo') + .input-group + = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true + .input-group-btn + = clipboard_button(target: '#domain_verification', class: 'btn-default hidden-xs') + %p.help-block - help_link = help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') - To #{link_to 'verify ownership', help_link} of your domain, create - this DNS record: - %pre - #{@domain.verification_domain} TXT #{@domain.keyed_verification_code} - %p - - if @domain.verified? - #{@domain.domain} has been successfully verified. - - else - = button_to 'Verify ownership', verify_project_pages_domain_path(@project, @domain), class: 'btn btn-save btn-sm' + To #{link_to 'verify ownership', help_link} of your domain, + add the above key to a TXT record within to your DNS configuration. %tr %td diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 877101b05ca..8f2142af2ce 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -1,24 +1,25 @@ - breadcrumb_title "Pipelines" -- page_title "New Pipeline" +- page_title = s_("Pipeline|Run Pipeline") %h3.page-title - New Pipeline + = s_("Pipeline|Run Pipeline") %hr = form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "form-horizontal js-new-pipeline-form js-requires-input" } do |f| = form_errors(@pipeline) .form-group - = f.label :ref, 'Create for', class: 'control-label' + = f.label :ref, s_('Pipeline|Run on'), class: 'control-label' .col-sm-10 = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch = dropdown_tag(params[:ref] || @project.default_branch, options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle', - filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search branches", + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"), data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) - .help-block Existing branch name, tag + .help-block + = s_("Pipeline|Existing branch name, tag") .form-actions - = f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3 - = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-cancel' + = f.submit s_('Pipeline|Run pipeline'), class: 'btn btn-success', tabindex: 3 + = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-default pull-right' -# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index a09c13176c3..300055a4207 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -1,4 +1,4 @@ -.panel.panel-default.protected-branches-list.js-protected-branches-list +.protected-branches-list.js-protected-branches-list - if @protected_branches.empty? .panel-heading %h3.panel-title diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml index 02908e16dc5..3ed82e51dbe 100644 --- a/app/views/projects/protected_tags/shared/_tags_list.html.haml +++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml @@ -1,4 +1,4 @@ -.panel.panel-default.protected-tags-list.js-protected-tags-list +.protected-tags-list.js-protected-tags-list - if @protected_tags.empty? .panel-heading %h3.panel-title diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 12d56e244ce..2c80f7c3fa3 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -29,6 +29,10 @@ docker login #{Gitlab.config.registry.host_port} %br %p + - deploy_token = link_to(_('deploy token'), help_page_path('user/projects/deploy_tokens/index', anchor: 'read-container-registry-images'), target: '_blank') + = s_('ContainerRegistry|You can also %{deploy_token} for read-only access to the registry images.').html_safe % { deploy_token: deploy_token } + %br + %p = s_('ContainerRegistry|Once you log in, you’re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe } %pre :plain diff --git a/app/views/projects/settings/badges/index.html.haml b/app/views/projects/settings/badges/index.html.haml new file mode 100644 index 00000000000..b68ed70de89 --- /dev/null +++ b/app/views/projects/settings/badges/index.html.haml @@ -0,0 +1,4 @@ +- breadcrumb_title _('Badges') +- page_title _('Badges') + += render 'shared/badges/badge_settings' diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml index e8028059487..e8028059487 100644 --- a/app/views/projects/pipelines_settings/_badge.html.haml +++ b/app/views/projects/settings/ci_cd/_badge.html.haml diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 646c01c0989..20868f9ba5d 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -1,6 +1,7 @@ .row.prepend-top-default .col-lg-12 - = form_for @project, url: project_pipelines_settings_path(@project) do |f| + = form_for @project, url: project_settings_ci_cd_path(@project) do |f| + = form_errors(@project) %fieldset.builds-feature .form-group %h5 Auto DevOps (Beta) @@ -73,10 +74,10 @@ %hr .form-group - = f.label :build_timeout_in_minutes, 'Timeout', class: 'label-light' - = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' + = f.label :build_timeout_human_readable, 'Timeout', class: 'label-light' + = f.text_field :build_timeout_human_readable, class: 'form-control' %p.help-block - Per job in minutes. If a job passes this threshold, it will be marked as failed + Per job. If a job passes this threshold, it will be marked as failed = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr @@ -151,10 +152,13 @@ %li excoveralls (Elixir) - %code \[TOTAL\]\s+(\d+\.\d+)% + %li + JaCoCo (Java/Kotlin) + %code Total.*?([0-9]{1,3})% = f.submit 'Save changes', class: "btn btn-save" %hr .row.prepend-top-default - = render partial: 'projects/pipelines_settings/badge', collection: @badges + = render partial: 'badge', collection: @badges diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index d65341dbd40..09268c9943b 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -3,8 +3,9 @@ - page_title "CI / CD" - expanded = Rails.env.test? +- general_expanded = @project.errors.empty? ? expanded : true -%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if expanded) } +%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } .settings-header %h4 General pipelines settings @@ -13,7 +14,7 @@ %p Update your CI/CD configuration, like job timeout or Auto DevOps. .settings-content - = render 'projects/pipelines_settings/show' + = render 'form' %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 6bef4d19434..f57590a908f 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -9,3 +9,4 @@ = render "projects/protected_branches/index" = render "projects/protected_tags/index" = render @deploy_keys += render "projects/deploy_tokens/index" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 94331a16abd..e28accd5b43 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -24,7 +24,7 @@ .text-warning.center.prepend-top-20 %p = icon("exclamation-triangle fw") - #{ _('Archived project! Repository is read-only') } + #{ _('Archived project! Repository and other project resources are read-only') } - view_path = @project.default_view diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 3d5f92f9aaa..98b4d6339da 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -31,6 +31,6 @@ = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do = icon("pencil") - - if can?(current_user, :admin_project, @project) - = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip prepend-left-10 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do - = icon("trash-o") + - if can?(current_user, :admin_project, @project) + = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip prepend-left-10 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do + = icon("trash-o") diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index dfe2c37ed8e..7a3469cdd26 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -28,7 +28,7 @@ = icon('history') .btn-container.controls-item = render 'projects/buttons/download', project: @project, ref: @tag.name - - if can?(current_user, :admin_project, @project) + - if can?(current_user, :push_code, @project) && can?(current_user, :admin_project, @project) .btn-container.controls-item-full = link_to project_tag_path(@project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: @tag.name } } do %i.fa.fa-trash-o diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 5ef5e9c09a2..8587d3b0c0d 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,3 +1,6 @@ +- can_collaborate = can_collaborate_with_project?(@project) +- can_create_mr_from_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) + .tree-ref-container .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true @@ -15,7 +18,7 @@ %li = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - if current_user + - if can_collaborate || can_create_mr_from_fork %li %a.btn.add-to-tree{ addtotree_toggle_attributes } = sprite_icon('plus', size: 16, css_class: 'pull-left') @@ -35,7 +38,7 @@ %li = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) + - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) %li - continue_params = { to: project_new_blob_path(@project, @id), notice: edit_in_new_fork_notice, @@ -61,23 +64,25 @@ = link_to fork_path, method: :post do #{ _('New directory') } - %li.divider - %li.dropdown-header - #{ _('This repository') } - %li - = link_to new_project_branch_path(@project) do - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - #{ _('New tag') } + - if can?(current_user, :push_code, @project) + %li.divider + %li.dropdown-header + #{ _('This repository') } + %li + = link_to new_project_branch_path(@project) do + #{ _('New branch') } + %li + = link_to new_project_tag_path(@project) do + #{ _('New tag') } .tree-controls = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = render 'projects/find_file_link' - = succeed " " do - = link_to ide_edit_path(@project, @id, ""), class: 'btn btn-default' do - = _('Web IDE') + - if can_collaborate + = succeed " " do + = link_to ide_edit_path(@project, @id, ""), class: 'btn btn-default' do + = _('Web IDE') = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index 934d65e8b42..e9ac192f5f7 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -1,14 +1,14 @@ -.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } } +.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20.prepend-top-10{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } } .banner-graphic = custom_icon('icon_autodevops') - .prepend-top-10.prepend-left-10.append-bottom-10 - %h5= s_('AutoDevOps|Auto DevOps (Beta)') + .banner-body.prepend-left-10.append-bottom-10 + %h5.banner-title= s_('AutoDevOps|Auto DevOps (Beta)') %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') %p - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } - .prepend-top-10 + .banner-buttons = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn js-close-callout' %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button', diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 56403907844..836df57a3a2 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -47,20 +47,20 @@ class: 'text-danger' .pull-right.hidden-xs.hidden-sm - - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) - %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'), - disabled: true, - type: 'button', - data: { url: promote_project_label_path(label.project, label), - label_title: label.title, - label_color: label.color, - label_text_color: label.text_color, - group_name: label.project.group.name, - target: '#promote-label-modal', - container: 'body', - toggle: 'modal' } } - = sprite_icon('level-up') - if can?(current_user, :admin_label, label) + - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) + %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'), + disabled: true, + type: 'button', + data: { url: promote_project_label_path(label.project, label), + label_title: label.title, + label_color: label.color, + label_text_color: label.text_color, + group_name: label.project.group.name, + target: '#promote-label-modal', + container: 'body', + toggle: 'modal' } } + = sprite_icon('level-up') = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do %span.sr-only Edit = sprite_icon('pencil') diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml index 93a4301f366..a0ba1afc284 100644 --- a/app/views/shared/_recaptcha_form.html.haml +++ b/app/views/shared/_recaptcha_form.html.haml @@ -10,7 +10,7 @@ = hidden_field(resource_name, field, value: value) = hidden_field_tag(:spam_log_id, spammable.spam_log.id) = hidden_field_tag(:recaptcha_verification, true) - = recaptcha_tags script: script, callback: 'recaptchaDialogCallback' + = recaptcha_tags script: script, callback: 'recaptchaDialogCallback' unless Rails.env.test? -# Yields a block with given extra params. = yield diff --git a/app/views/shared/badges/_badge_settings.html.haml b/app/views/shared/badges/_badge_settings.html.haml new file mode 100644 index 00000000000..b7c250d3b1c --- /dev/null +++ b/app/views/shared/badges/_badge_settings.html.haml @@ -0,0 +1,4 @@ +#badge-settings{ data: { api_endpoint_url: @badge_api_endpoint, + docs_url: help_page_path('user/project/badges')} } + .text-center.prepend-top-default + = icon('spinner spin 2x') diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml index 8e5e32e9f16..b385cc3f962 100644 --- a/app/views/shared/boards/components/_sidebar.html.haml +++ b/app/views/shared/boards/components/_sidebar.html.haml @@ -22,6 +22,6 @@ = render "shared/boards/components/sidebar/labels" = render "shared/boards/components/sidebar/notifications" %remove-btn{ ":issue" => "issue", - ":issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'", + ":issue-update" => "issue.sidebarInfoEndpoint", ":list" => "list", "v-if" => "canRemove" } diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml index 3d2e8471a60..1374da9d82c 100644 --- a/app/views/shared/boards/components/sidebar/_assignee.html.haml +++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml @@ -21,8 +21,7 @@ .dropdown - dropdown_options = issue_assignees_dropdown_options %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data, - ":data-issuable-id" => "issue.iid", - ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" } + ":data-issuable-id" => "issue.iid" } = dropdown_options[:title] = icon("chevron-down") .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml index db794d6f855..d13b998e6f4 100644 --- a/app/views/shared/boards/components/sidebar/_due_date.html.haml +++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml @@ -22,8 +22,7 @@ ":value" => "issue.dueDate" } .dropdown %button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button', - data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" }, - ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" } + data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" } } %span.dropdown-toggle-text Due date = icon('chevron-down') .dropdown-menu.dropdown-menu-due-date diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index dfc0f9be321..87e6b52f46e 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -26,8 +26,7 @@ project_id: @project&.try(:id), labels: labels_filter_path(false), namespace_path: @namespace_path, - project_path: @project.try(:path) }, - ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" } + project_path: @project.try(:path) } } %span.dropdown-toggle-text Label = icon('chevron-down') diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml index d09c7c218e0..f51c4a97f2b 100644 --- a/app/views/shared/boards/components/sidebar/_milestone.html.haml +++ b/app/views/shared/boards/components/sidebar/_milestone.html.haml @@ -18,8 +18,7 @@ .dropdown %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" }, ":data-selected" => "milestoneTitle", - ":data-issuable-id" => "issue.iid", - ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" } + ":data-issuable-id" => "issue.iid" } Milestone = icon("chevron-down") .dropdown-menu.dropdown-select.dropdown-menu-selectable diff --git a/app/views/shared/dashboard/_no_filter_selected.html.haml b/app/views/shared/dashboard/_no_filter_selected.html.haml new file mode 100644 index 00000000000..b2e6967f6aa --- /dev/null +++ b/app/views/shared/dashboard/_no_filter_selected.html.haml @@ -0,0 +1,8 @@ +.row.empty-state.text-center + .col-xs-12 + .svg-130.prepend-top-default + = image_tag 'illustrations/issue-dashboard_results-without-filter.svg' + .col-xs-12 + .text-content + %h4 + = _("Please select at least one filter to see results") diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 7704c88905b..1bd5b4164b1 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -24,12 +24,9 @@ .filter-item.inline.labels-filter = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" } - - if issuable_filter_present? - .filter-item.inline.reset-filters - %a{ href: page_filter_path(without: issuable_filter_params) } Reset filters - - .pull-right - = render 'shared/sort_dropdown' + - unless @no_filters_set + .pull-right + = render 'shared/sort_dropdown' - has_labels = @labels && @labels.any? .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) } diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml index 4d8109eb90c..a5f40ea934b 100644 --- a/app/views/shared/issuable/_nav.html.haml +++ b/app/views/shared/issuable/_nav.html.haml @@ -1,22 +1,23 @@ - type = local_assigns.fetch(:type, :issues) - page_context_word = type.to_s.humanize(capitalize: false) +- display_count = local_assigns.fetch(:display_count, :true) %ul.nav-links.issues-state-filters.mobile-separator %li{ class: active_when(params[:state] == 'opened') }> = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do - #{issuables_state_counter_text(type, :opened)} + #{issuables_state_counter_text(type, :opened, display_count)} - if type == :merge_requests %li{ class: active_when(params[:state] == 'merged') }> = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do - #{issuables_state_counter_text(type, :merged)} + #{issuables_state_counter_text(type, :merged, display_count)} %li{ class: active_when(params[:state] == 'closed') }> = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do - #{issuables_state_counter_text(type, :closed)} + #{issuables_state_counter_text(type, :closed, display_count)} - else %li{ class: active_when(params[:state] == 'closed') }> = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do - #{issuables_state_counter_text(type, :closed)} + #{issuables_state_counter_text(type, :closed, display_count)} - = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all) + = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 6afcd447f28..975b9cb4729 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -107,7 +107,7 @@ - selected_labels.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (project_labels_path(@project, :json) if @project) } } + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (labels_filter_path(false) if @project) } } %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 5868c52566d..fc634856061 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -8,7 +8,7 @@ %strong = link_to group.full_name, group_path(group) .cgray - Joined #{time_ago_with_tooltip(group.created_at)} + Given access #{time_ago_with_tooltip(group_link.created_at)} - if group_link.expires? · %span{ class: ('text-warning' if group_link.expires_soon?) } diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index ba57d922c6d..1c139827acf 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -29,7 +29,7 @@ Requested = time_ago_with_tooltip(member.requested_at) - else - Joined #{time_ago_with_tooltip(member.created_at)} + Given access #{time_ago_with_tooltip(member.created_at)} - if member.expires? · %span{ class: "#{"text-warning" if member.expires_soon?} has-tooltip", title: member.expires_at.to_time.in_time_zone.to_s(:medium) } diff --git a/app/views/shared/milestones/_deprecation_message.html.haml b/app/views/shared/milestones/_deprecation_message.html.haml new file mode 100644 index 00000000000..4a8f90937ea --- /dev/null +++ b/app/views/shared/milestones/_deprecation_message.html.haml @@ -0,0 +1,14 @@ +.banner-callout.compact.milestone-deprecation-message.js-milestone-deprecation-message.prepend-top-20 + .banner-graphic= image_tag 'illustrations/milestone_removing-page.svg' + .banner-body.prepend-left-10.append-right-10 + %h5.banner-title.prepend-top-0= _('This page will be removed in a future release.') + %p.milestone-banner-text= _('Use group milestones to manage issues from multiple projects in the same milestone.') + = button_tag _('Promote these project milestones into a group milestone.'), class: 'btn btn-link js-popover-link text-align-left milestone-banner-link' + .milestone-banner-buttons.prepend-top-20= link_to _('Learn more'), help_page_url('user/project/milestones/index', anchor: 'promoting-project-milestones-to-group-milestones'), class: 'btn btn-default', target: '_blank' + + %template.js-milestone-deprecation-message-template + .milestone-popover-body + %ol.milestone-popover-instructions-list.append-bottom-0 + %li= _('Click any <strong>project name</strong> in the project list below to navigate to the project milestone.').html_safe + %li= _('Click the <strong>Promote</strong> button in the top right corner to promote it to a group milestone.').html_safe + .milestone-popover-footer= link_to _('Learn more'), help_page_url('user/project/milestones/index', anchor: 'promoting-project-milestones-to-group-milestones'), class: 'btn btn-link prepend-left-0', target: '_blank' diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index a942ebc328b..ee134480705 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -72,7 +72,7 @@ .title.hide-collapsed Issues %span.badge= milestone.issues_visible_to_user(current_user).count - - if project && can?(current_user, :create_issue, project) + - if show_new_issue_link?(project) = link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "pull-right", title: "New Issue" do New issue .value.hide-collapsed.bold diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index f302299eb24..797ff034bb2 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -1,7 +1,8 @@ -- page_title @milestone.title +- page_title milestone.title - @breadcrumb_link = dashboard_milestone_path(milestone.safe_title, title: milestone.title) - group = local_assigns[:group] +- is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone? .detail-page-header %a.btn.btn-default.btn-grouped.pull-right.visible-xs-block.js-sidebar-toggle{ href: "#" } @@ -31,21 +32,23 @@ - else = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" += render 'shared/milestones/deprecation_message' if is_dynamic_milestone + .detail-page-description.milestone-detail %h2.title = markdown_field(milestone, :title) - - if @milestone.group_milestone? && @milestone.description.present? + - if milestone.group_milestone? && milestone.description.present? %div .description .wiki - = markdown_field(@milestone, :description) + = markdown_field(milestone, :description) - if milestone.complete?(current_user) && milestone.active? .alert.alert-success.prepend-top-default - close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.' %span All issues for this milestone are closed. #{close_msg} -- if @milestone.legacy_group_milestone? || @milestone.dashboard_milestone? +- if is_dynamic_milestone .table-holder %table.table %thead @@ -68,7 +71,7 @@ Open %td = ms.expires_at -- elsif @milestone.group_milestone? +- elsif milestone.group_milestone? %br View = link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title) diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index bf359774ead..893a7f26ebd 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -2,7 +2,7 @@ - return if note.cross_reference_not_visible_for?(current_user) - show_image_comment_badge = local_assigns.fetch(:show_image_comment_badge, false) -- note_editable = note_editable?(note) +- note_editable = can?(current_user, :admin_note, note) - note_counter = local_assigns.fetch(:note_counter, 0) %li.timeline-entry{ id: dom_id(note), diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml new file mode 100644 index 00000000000..2d93e51a2d9 --- /dev/null +++ b/app/views/shared/snippets/_embed.html.haml @@ -0,0 +1,24 @@ +- blob = @snippet.blob +.gitlab-embed-snippets + .js-file-title.file-title-flex-parent + .file-header-content + = external_snippet_icon('doc_text') + + %strong.file-title-name + %a.gitlab-embedded-snippets-title{ href: url_for(only_path: false, overwrite_params: nil) } + = blob.name + + %small + = number_to_human_size(blob.raw_size) + %a.gitlab-logo{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' } + on + %span.logo-text + GitLab + + .file-actions.hidden-xs + .btn-group{ role: "group" }< + = embedded_snippet_raw_button + + = embedded_snippet_download_button + %article.file-holder.snippet-file-content + = render 'projects/blob/viewer', viewer: @snippet.blob.simple_viewer, load_async: false, external_embed: true diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 12df79a28c7..836230ae8ee 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -19,11 +19,32 @@ %h2.snippet-title.prepend-top-0.append-bottom-0 = markdown_field(@snippet, :title) - - if @snippet.updated_at != @snippet.created_at - = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) - if @snippet.description.present? .description .wiki = markdown_field(@snippet, :description) %textarea.hidden.js-task-list-field = @snippet.description + + - if @snippet.updated_at != @snippet.created_at + = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) + + - if public_snippet? + .embed-snippet + .input-group + .input-group-btn + %button.btn.embed-toggle{ 'data-toggle': 'dropdown', type: 'button' } + %span.js-embed-action= _("Embed") + = sprite_icon('angle-down', size: 12) + %ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list + %li + %button.js-embed-btn.btn.btn-transparent.is-active{ type: 'button' } + %strong.embed-toggle-list-item= _("Embed") + %li + %button.js-share-btn.btn.btn-transparent{ type: 'button' } + %strong.embed-toggle-list-item= _("Share") + %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed } + .input-group-btn + %button.js-clipboard-btn.btn.btn-default.has-tooltip{ title: "Copy to clipboard", 'data-clipboard-target': '#snippet-url-area' } + = sprite_icon('duplicate', size: 16) + .clearfix diff --git a/app/views/shared/snippets/show.js.haml b/app/views/shared/snippets/show.js.haml new file mode 100644 index 00000000000..a9af732bbb5 --- /dev/null +++ b/app/views/shared/snippets/show.js.haml @@ -0,0 +1,2 @@ +document.write('#{escape_javascript(stylesheet_link_tag "#{stylesheet_url 'snippets'}")}'); +document.write('#{escape_javascript(render 'shared/snippets/embed')}'); diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index ad4d39b4aa1..d36ca032558 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -33,6 +33,13 @@ %p.light This URL will be triggered when someone adds a comment %li + = form.check_box :confidential_note_events, class: 'pull-left' + .prepend-left-20 + = form.label :confidential_note_events, class: 'list-label' do + %strong Confidential Comments + %p.light + This URL will be triggered when someone adds a comment on a confidential issue + %li = form.check_box :issues_events, class: 'pull-left' .prepend-left-20 = form.label :issues_events, class: 'list-label' do diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index d7e24491516..8fe3619f6ee 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -2,6 +2,14 @@ class AuthorizedProjectsWorker include ApplicationWorker prepend WaitableWorker + # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the + # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231 + # for more details. + if Rails.env.test? + def self.bulk_perform_and_wait(args_list, timeout: 10) + end + end + def perform(user_id) user = User.find_by(id: user_id) diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index 67c54fbf10e..b925741934a 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -5,7 +5,7 @@ class NewNoteWorker # old `NewNoteWorker` jobs (can remove later) def perform(note_id, _params = {}) if note = Note.find_by(id: note_id) - NotificationService.new.new_note(note) + NotificationService.new.new_note(note) if note.can_create_notification? Notes::PostProcessService.new(note).execute else Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job") |