diff options
Diffstat (limited to 'app')
140 files changed, 2726 insertions, 903 deletions
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 19a607309e4..23d91fdb259 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -62,6 +62,7 @@ function glEmojiTag(inputName, options) { data-fallback-src="${fallbackImageSrc}" ${fallbackSpriteAttribute} data-unicode-version="${emojiInfo.unicodeVersion}" + title="${emojiInfo.description}" > ${contents} </gl-emoji> diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index e704be8b53e..ad9c600b499 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -46,6 +46,7 @@ export default Vue.component('pipelines-table', { isLoading: false, hasError: false, isMakingRequest: false, + updateGraphDropdown: false, }; }, @@ -130,15 +131,21 @@ export default Vue.component('pipelines-table', { const pipelines = response.pipelines || response; this.store.storePipelines(pipelines); this.isLoading = false; + this.updateGraphDropdown = true; }, errorCallback() { this.hasError = true; this.isLoading = false; + this.updateGraphDropdown = false; }, setIsMakingRequest(isMakingRequest) { this.isMakingRequest = isMakingRequest; + + if (isMakingRequest) { + this.updateGraphDropdown = false; + } }, }, @@ -163,7 +170,9 @@ export default Vue.component('pipelines-table', { v-if="shouldRenderTable"> <pipelines-table-component :pipelines="state.pipelines" - :service="service" /> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </div> </div> `, diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js new file mode 100644 index 00000000000..ff2f2c81971 --- /dev/null +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -0,0 +1,193 @@ +/* eslint-disable no-new */ +/* global Flash */ +import DropLab from './droplab/drop_lab'; +import ISetter from './droplab/plugins/input_setter'; + +// Todo: Remove this when fixing issue in input_setter plugin +const InputSetter = Object.assign({}, ISetter); + +const CREATE_MERGE_REQUEST = 'create-mr'; +const CREATE_BRANCH = 'create-branch'; + +export default class CreateMergeRequestDropdown { + constructor(wrapperEl) { + this.wrapperEl = wrapperEl; + this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request'); + this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); + this.availableButton = this.wrapperEl.querySelector('.available'); + this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); + this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa'); + this.unavailableButtonText = this.unavailableButton.querySelector('.text'); + + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.canCreatePath = this.wrapperEl.dataset.canCreatePath; + this.createMrPath = this.wrapperEl.dataset.createMrPath; + this.droplabInitialized = false; + this.isCreatingMergeRequest = false; + this.mergeRequestCreated = false; + this.isCreatingBranch = false; + this.branchCreated = false; + + this.init(); + } + + init() { + this.checkAbilityToCreateBranch(); + } + + available() { + this.availableButton.classList.remove('hide'); + this.unavailableButton.classList.add('hide'); + } + + unavailable() { + this.availableButton.classList.add('hide'); + this.unavailableButton.classList.remove('hide'); + } + + enable() { + this.createMergeRequestButton.classList.remove('disabled'); + this.createMergeRequestButton.removeAttribute('disabled'); + + this.dropdownToggle.classList.remove('disabled'); + this.dropdownToggle.removeAttribute('disabled'); + } + + disable() { + this.createMergeRequestButton.classList.add('disabled'); + this.createMergeRequestButton.setAttribute('disabled', 'disabled'); + + this.dropdownToggle.classList.add('disabled'); + this.dropdownToggle.setAttribute('disabled', 'disabled'); + } + + hide() { + this.wrapperEl.classList.add('hide'); + } + + setUnavailableButtonState(isLoading = true) { + if (isLoading) { + this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin'); + this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = 'Checking branch availability…'; + } else { + this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin'); + this.unavailableButtonArrow.classList.add('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = 'New branch unavailable'; + } + } + + checkAbilityToCreateBranch() { + return $.ajax({ + type: 'GET', + dataType: 'json', + url: this.canCreatePath, + beforeSend: () => this.setUnavailableButtonState(), + }) + .done((data) => { + this.setUnavailableButtonState(false); + + if (data.can_create_branch) { + this.available(); + this.enable(); + + if (!this.droplabInitialized) { + this.droplabInitialized = true; + this.initDroplab(); + this.bindEvents(); + } + } else if (data.has_related_branch) { + this.hide(); + } + }).fail(() => { + this.unavailable(); + this.disable(); + new Flash('Failed to check if a new branch can be created.'); + }); + } + + initDroplab() { + this.droplab = new DropLab(); + + this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter], + this.getDroplabConfig()); + } + + getDroplabConfig() { + return { + InputSetter: [{ + input: this.createMergeRequestButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, { + input: this.createMergeRequestButton, + valueAttribute: 'data-text', + }], + }; + } + + bindEvents() { + this.createMergeRequestButton + .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + } + + isBusy() { + return this.isCreatingMergeRequest || + this.mergeRequestCreated || + this.isCreatingBranch || + this.branchCreated; + } + + onClickCreateMergeRequestButton(e) { + let xhr = null; + e.preventDefault(); + + if (this.isBusy()) { + return; + } + + if (e.target.dataset.action === CREATE_MERGE_REQUEST) { + xhr = this.createMergeRequest(); + } else if (e.target.dataset.action === CREATE_BRANCH) { + xhr = this.createBranch(); + } + + xhr.fail(() => { + this.isCreatingMergeRequest = false; + this.isCreatingBranch = false; + }); + + xhr.always(() => this.enable()); + + this.disable(); + } + + createMergeRequest() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createMrPath, + beforeSend: () => (this.isCreatingMergeRequest = true), + }) + .done((data) => { + this.mergeRequestCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create Merge Request. Please try again.')); + } + + createBranch() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createBranchPath, + beforeSend: () => (this.isCreatingBranch = true), + }) + .done((data) => { + this.branchCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); + } +} diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue new file mode 100644 index 00000000000..3ff3a9d977e --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -0,0 +1,54 @@ +<script> + import eventHub from '../eventhub'; + + export default { + data() { + return { + isLoading: false, + }; + }, + props: { + deployKey: { + type: Object, + required: true, + }, + type: { + type: String, + required: true, + }, + btnCssClass: { + type: String, + required: false, + default: 'btn-default', + }, + }, + methods: { + doAction() { + this.isLoading = true; + + eventHub.$emit(`${this.type}.key`, this.deployKey); + }, + }, + computed: { + text() { + return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`; + }, + }, + }; +</script> + +<template> + <button + class="btn btn-sm prepend-left-10" + :class="[{ disabled: isLoading }, btnCssClass]" + :disabled="isLoading" + @click="doAction"> + {{ text }} + <i + v-if="isLoading" + class="fa fa-spinner fa-spin" + aria-hidden="true" + aria-label="Loading"> + </i> + </button> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue new file mode 100644 index 00000000000..7315a9e11cb --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -0,0 +1,102 @@ +<script> + /* global Flash */ + import eventHub from '../eventhub'; + import DeployKeysService from '../service'; + import DeployKeysStore from '../store'; + import keysPanel from './keys_panel.vue'; + + export default { + data() { + return { + isLoading: false, + store: new DeployKeysStore(), + }; + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + computed: { + hasKeys() { + return Object.keys(this.keys).length; + }, + keys() { + return this.store.keys; + }, + }, + components: { + keysPanel, + }, + methods: { + fetchKeys() { + this.isLoading = true; + + this.service.getKeys() + .then((data) => { + this.isLoading = false; + this.store.keys = data; + }) + .catch(() => new Flash('Error getting deploy keys')); + }, + enableKey(deployKey) { + this.service.enableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error enabling deploy key')); + }, + disableKey(deployKey) { + // eslint-disable-next-line no-alert + if (confirm('You are going to remove this deploy key. Are you sure?')) { + this.service.disableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error removing deploy key')); + } + }, + }, + created() { + this.service = new DeployKeysService(this.endpoint); + + eventHub.$on('enable.key', this.enableKey); + eventHub.$on('remove.key', this.disableKey); + eventHub.$on('disable.key', this.disableKey); + }, + mounted() { + this.fetchKeys(); + }, + beforeDestroy() { + eventHub.$off('enable.key', this.enableKey); + eventHub.$off('remove.key', this.disableKey); + eventHub.$off('disable.key', this.disableKey); + }, + }; +</script> + +<template> + <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys"> + <div + class="text-center" + v-if="isLoading && !hasKeys"> + <i + class="fa fa-spinner fa-spin fa-2x" + aria-hidden="true" + aria-label="Loading deploy keys"> + </i> + </div> + <div v-else-if="hasKeys"> + <keys-panel + title="Enabled deploy keys for this project" + :keys="keys.enabled_keys" + :store="store" /> + <keys-panel + title="Deploy keys from projects you have access to" + :keys="keys.available_project_keys" + :store="store" /> + <keys-panel + v-if="keys.public_keys.length" + title="Public deploy keys available to any project" + :keys="keys.public_keys" + :store="store" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue new file mode 100644 index 00000000000..0a06a481b96 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -0,0 +1,80 @@ +<script> + import actionBtn from './action_btn.vue'; + + export default { + props: { + deployKey: { + type: Object, + required: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + actionBtn, + }, + computed: { + timeagoDate() { + return gl.utils.getTimeago().format(this.deployKey.created_at); + }, + }, + methods: { + isEnabled(id) { + return this.store.findEnabledKey(id) !== undefined; + }, + }, + }; +</script> + +<template> + <div> + <div class="pull-left append-right-10 hidden-xs"> + <i + aria-hidden="true" + class="fa fa-key key-icon"> + </i> + </div> + <div class="deploy-key-content key-list-item-info"> + <strong class="title"> + {{ deployKey.title }} + </strong> + <div class="description"> + {{ deployKey.fingerprint }} + </div> + <div + v-if="deployKey.can_push" + class="write-access-allowed"> + Write access allowed + </div> + </div> + <div class="deploy-key-content prepend-left-default deploy-key-projects"> + <a + v-for="project in deployKey.projects" + class="label deploy-project-label" + :href="project.full_path"> + {{ project.full_name }} + </a> + </div> + <div class="deploy-key-content"> + <span class="key-created-at"> + created {{ timeagoDate }} + </span> + <action-btn + v-if="!isEnabled(deployKey.id)" + :deploy-key="deployKey" + type="enable"/> + <action-btn + v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="remove" /> + <action-btn + v-else + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="disable" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue new file mode 100644 index 00000000000..eccc470578b --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -0,0 +1,52 @@ +<script> + import key from './key.vue'; + + export default { + props: { + title: { + type: String, + required: true, + }, + keys: { + type: Array, + required: true, + }, + showHelpBox: { + type: Boolean, + required: false, + default: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + key, + }, + }; +</script> + +<template> + <div class="deploy-keys-panel"> + <h5> + {{ title }} + ({{ keys.length }}) + </h5> + <ul class="well-list" + v-if="keys.length"> + <li + v-for="deployKey in keys" + :key="deployKey.id"> + <key + :deploy-key="deployKey" + :store="store" /> + </li> + </ul> + <div + class="settings-message text-center" + v-else-if="showHelpBox"> + No deploy keys found. Create one with the form above. + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/eventhub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js new file mode 100644 index 00000000000..a5f232f950a --- /dev/null +++ b/app/assets/javascripts/deploy_keys/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import deployKeysApp from './components/app.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: document.getElementById('js-deploy-keys'), + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + }; + }, + components: { + deployKeysApp, + }, + render(createElement) { + return createElement('deploy-keys-app', { + props: { + endpoint: this.endpoint, + }, + }); + }, +})); diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js new file mode 100644 index 00000000000..fe6dbaa9498 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/service/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class DeployKeysService { + constructor(endpoint) { + this.endpoint = endpoint; + + this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, { + enable: { + method: 'PUT', + url: `${this.endpoint}{/id}/enable`, + }, + disable: { + method: 'PUT', + url: `${this.endpoint}{/id}/disable`, + }, + }); + } + + getKeys() { + return this.resource.get() + .then(response => response.json()); + } + + enableKey(id) { + return this.resource.enable({ id }, {}); + } + + disableKey(id) { + return this.resource.disable({ id }, {}); + } +} diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js new file mode 100644 index 00000000000..6210361af26 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/store/index.js @@ -0,0 +1,9 @@ +export default class DeployKeysStore { + constructor() { + this.keys = {}; + } + + findEnabledKey(id) { + return this.keys.enabled_keys.find(key => key.id === id); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 0bdce52cc89..b87c57c38fe 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -50,6 +50,7 @@ import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; import BlobViewer from './blob/viewer/index'; +import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -198,6 +199,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); new LabelsSelect(); new MilestoneSelect(); new gl.IssuableTemplateSelectors(); + new AutoWidthDropdownSelect($('.js-target-branch-select')).init(); break; case 'projects:tags:new': new ZenMode(); @@ -344,6 +346,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:artifacts:browse': new BuildArtifacts(); break; + case 'projects:artifacts:file': + new BlobViewer(); + break; case 'help:index': gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); break; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index b70d242269d..b3a76fbb43e 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -5,7 +5,7 @@ require('./preview_markdown'); window.DropzoneInput = (function() { function DropzoneInput(form) { - var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, project_uploads_path, showError, showSpinner, uploadFile, uploadProgress; + var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, uploads_path, showError, showSpinner, uploadFile, uploadProgress; Dropzone.autoDiscover = false; alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; @@ -16,7 +16,7 @@ window.DropzoneInput = (function() { iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>"; uploadProgress = $("<div class=\"div-dropzone-progress\"></div>"); btnAlert = "<button type=\"button\"" + alertAttr + ">×</button>"; - project_uploads_path = window.project_uploads_path || null; + uploads_path = window.uploads_path || null; max_file_size = gon.max_file_size || 10; form_textarea = $(form).find(".js-gfm-input"); form_textarea.wrap("<div class=\"div-dropzone\"></div>"); @@ -39,10 +39,10 @@ window.DropzoneInput = (function() { "display": "none" }); - if (!project_uploads_path) return; + if (!uploads_path) return; dropzone = form_dropzone.dropzone({ - url: project_uploads_path, + url: uploads_path, dictDefaultMessage: "", clickable: true, paramName: "file", @@ -159,7 +159,7 @@ window.DropzoneInput = (function() { formData = new FormData(); formData.append("file", item, filename); return $.ajax({ - url: project_uploads_path, + url: uploads_path, type: "POST", data: formData, dataType: "json", diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue index f319d6ca0c8..e0088d496eb 100644 --- a/app/assets/javascripts/environments/components/environment.vue +++ b/app/assets/javascripts/environments/components/environment.vue @@ -1,6 +1,4 @@ <script> - -/* eslint-disable no-new */ /* global Flash */ import EnvironmentsService from '../services/environments_service'; import EnvironmentTable from './environments_table.vue'; @@ -71,11 +69,13 @@ export default { eventHub.$on('refreshEnvironments', this.fetchEnvironments); eventHub.$on('toggleFolder', this.toggleFolder); + eventHub.$on('postAction', this.postAction); }, beforeDestroyed() { eventHub.$off('refreshEnvironments'); eventHub.$off('toggleFolder'); + eventHub.$off('postAction'); }, methods: { @@ -122,6 +122,7 @@ export default { }) .catch(() => { this.isLoading = false; + // eslint-disable-next-line no-new new Flash('An error occurred while fetching the environments.'); }); }, @@ -137,9 +138,16 @@ export default { }) .catch(() => { this.isLoadingFolderContent = false; + // eslint-disable-next-line no-new new Flash('An error occurred while fetching the environments.'); }); }, + + postAction(endpoint) { + this.service.postAction(endpoint) + .then(() => this.fetchEnvironments()) + .catch(() => new Flash('An error occured while making the request.')); + }, }, }; </script> @@ -217,7 +225,6 @@ export default { :environments="state.environments" :can-create-deployment="canCreateDeploymentParsed" :can-read-environment="canReadEnvironmentParsed" - :service="service" :is-loading-folder-content="isLoadingFolderContent" /> </div> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index e81c97260d7..63bffe8a998 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,7 +1,4 @@ <script> -/* global Flash */ -/* eslint-disable no-new */ - import playIconSvg from 'icons/_icon_play.svg'; import eventHub from '../event_hub'; @@ -12,11 +9,6 @@ export default { required: false, default: () => [], }, - - service: { - type: Object, - required: true, - }, }, data() { @@ -38,15 +30,7 @@ export default { $(this.$refs.tooltip).tooltip('destroy'); - this.service.postAction(endpoint) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.'); - }); + eventHub.$emit('postAction', endpoint); }, isActionDisabled(action) { diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 73679de6039..0ffe9ea17fa 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -46,11 +46,6 @@ export default { required: false, default: false, }, - - service: { - type: Object, - required: true, - }, }, computed: { @@ -543,31 +538,34 @@ export default { <actions-component v-if="hasManualActions && canCreateDeployment" - :service="service" - :actions="manualActions"/> + :actions="manualActions" + /> <external-url-component v-if="externalURL && canReadEnvironment" - :external-url="externalURL"/> + :external-url="externalURL" + /> <monitoring-button-component v-if="monitoringUrl && canReadEnvironment" - :monitoring-url="monitoringUrl"/> + :monitoring-url="monitoringUrl" + /> <terminal-button-component v-if="model && model.terminal_path" - :terminal-path="model.terminal_path"/> + :terminal-path="model.terminal_path" + /> <stop-component v-if="hasStopAction && canCreateDeployment" :stop-url="model.stop_path" - :service="service"/> + /> <rollback-component v-if="canRetry && canCreateDeployment" :is-last-deployment="isLastDeployment" :retry-url="retryUrl" - :service="service"/> + /> </div> </td> </tr> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index f139f24036f..44b8730fd09 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -1,6 +1,4 @@ <script> -/* global Flash */ -/* eslint-disable no-new */ /** * Renders Rollback or Re deploy button in environments table depending * of the provided property `isLastDeployment`. @@ -20,11 +18,6 @@ export default { type: Boolean, default: true, }, - - service: { - type: Object, - required: true, - }, }, data() { @@ -37,17 +30,7 @@ export default { onClick() { this.isLoading = true; - $(this.$el).tooltip('destroy'); - - this.service.postAction(this.retryUrl) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.'); - }); + eventHub.$emit('postAction', this.retryUrl); }, }, }; diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index 11e9aff7b92..f483ea7e937 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -1,6 +1,4 @@ <script> -/* global Flash */ -/* eslint-disable no-new, no-alert */ /** * Renders the stop "button" that allows stop an environment. * Used in environments table. @@ -13,11 +11,6 @@ export default { type: String, default: '', }, - - service: { - type: Object, - required: true, - }, }, data() { @@ -34,20 +27,13 @@ export default { methods: { onClick() { + // eslint-disable-next-line no-alert if (confirm('Are you sure you want to stop this environment?')) { this.isLoading = true; $(this.$el).tooltip('destroy'); - this.service.postAction(this.retryUrl) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.', 'alert'); - }); + eventHub.$emit('postAction', this.stopUrl); } }, }, diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 87f7cb4a536..15eedaf76e1 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -28,11 +28,6 @@ export default { default: false, }, - service: { - type: Object, - required: true, - }, - isLoadingFolderContent: { type: Boolean, required: false, @@ -78,7 +73,7 @@ export default { :model="model" :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" - :service="service" /> + /> <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> <tr v-if="isLoadingFolderContent"> @@ -96,7 +91,7 @@ export default { :model="children" :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" - :service="service" /> + /> <tr> <td diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index d27b2acfcdf..f4a0c390c91 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable no-new */ /* global Flash */ import EnvironmentsService from '../services/environments_service'; import EnvironmentTable from '../components/environments_table.vue'; @@ -99,6 +98,7 @@ export default { }) .catch(() => { this.isLoading = false; + // eslint-disable-next-line no-new new Flash('An error occurred while fetching the environments.', 'alert'); }); }, @@ -169,7 +169,7 @@ export default { :environments="state.environments" :can-create-deployment="canCreateDeploymentParsed" :can-read-environment="canReadEnvironmentParsed" - :service="service"/> + /> <table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 687a462a0d4..f1b99023c72 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -101,9 +101,17 @@ window.gl.GfmAutoComplete = { } } }, - setup: function(input) { + setup: function(input, enableMap = { + emojis: true, + members: true, + issues: true, + milestones: true, + mergeRequests: true, + labels: true + }) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); + this.enableMap = enableMap; this.setupLifecycle(); }, setupLifecycle() { @@ -115,7 +123,84 @@ window.gl.GfmAutoComplete = { $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); }); }, + setupAtWho: function($input) { + if (this.enableMap.emojis) this.setupEmoji($input); + if (this.enableMap.members) this.setupMembers($input); + if (this.enableMap.issues) this.setupIssues($input); + if (this.enableMap.milestones) this.setupMilestones($input); + if (this.enableMap.mergeRequests) this.setupMergeRequests($input); + if (this.enableMap.labels) this.setupLabels($input); + + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + $input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + skipSpecialCharacterTest: true, + data: this.defaultLoadingData, + displayTpl: function(value) { + if (this.isLoading(value)) return this.Loading.template; + var tpl = '<li>/${name}'; + if (value.aliases.length > 0) { + tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; + } + if (value.params.length > 0) { + tpl += ' <small><%- params.join(" ") %></small>'; + } + if (value.description !== '') { + tpl += '<small class="description"><i><%- description %></i></small>'; + } + tpl += '</li>'; + return _.template(tpl)(value); + }.bind(this), + insertTpl: function(value) { + var tpl = "/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; + } + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + if (gl.GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); + }, + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; + } + } + } + }); + return; + }, + + setupEmoji($input) { // Emoji $input.atwho({ at: ':', @@ -139,6 +224,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMembers($input) { // Team Members $input.atwho({ at: '@', @@ -180,6 +268,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupIssues($input) { $input.atwho({ at: '#', alias: 'issues', @@ -208,6 +299,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMilestones($input) { $input.atwho({ at: '%', alias: 'milestones', @@ -236,6 +330,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMergeRequests($input) { $input.atwho({ at: '!', alias: 'mergerequests', @@ -264,6 +361,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupLabels($input) { $input.atwho({ at: '~', alias: 'labels', @@ -298,73 +398,8 @@ window.gl.GfmAutoComplete = { } } }); - // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - $input.filter('[data-supports-slash-commands="true"]').atwho({ - at: '/', - alias: 'commands', - searchKey: 'search', - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '<li>/${name}'; - if (value.aliases.length > 0) { - tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; - } - if (value.params.length > 0) { - tpl += ' <small><%- params.join(" ") %></small>'; - } - if (value.description !== '') { - tpl += '<small class="description"><i><%- description %></i></small>'; - } - tpl += '</li>'; - return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; - if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; - } - } - return _.template(tpl)({ reference_prefix: reference_prefix }); - }, - suffix: '', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; - if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); - } - return { - name: c.name, - aliases: c.aliases, - params: c.params, - description: c.description, - search: search - }; - }); - }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); - if (match) { - return match[1]; - } else { - return null; - } - } - } - }); - return; }, + fetchData: function($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index ff10f19a4fe..ff06092e4d6 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -34,9 +34,9 @@ GLForm.prototype.setupForm = function() { gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); - // form and textarea event listeners - this.addEventListeners(); } + // form and textarea event listeners + this.addEventListeners(); gl.text.init(this.form); // hide discard button this.form.find('.js-note-discard').hide(); diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js new file mode 100644 index 00000000000..2203a56315e --- /dev/null +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -0,0 +1,38 @@ +let instanceCount = 0; + +class AutoWidthDropdownSelect { + constructor(selectElement) { + this.$selectElement = $(selectElement); + this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`; + instanceCount += 1; + } + + init() { + const dropdownClass = this.dropdownClass; + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + dropdownCss() { + let resultantWidth = 'auto'; + const $dropdown = $(`.${dropdownClass}`); + + // We have to look at the parent because + // `offsetParent` on a `display: none;` is `null` + const offsetParentWidth = $(this).parent().offsetParent().width(); + // Reset any width to let it naturally flow + $dropdown.css('width', 'auto'); + if ($dropdown.outerWidth(false) > offsetParentWidth) { + resultantWidth = offsetParentWidth; + } + + return { + width: resultantWidth, + maxWidth: offsetParentWidth, + }; + }, + }); + + return this; + } +} + +export default AutoWidthDropdownSelect; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 011043e992f..694c6177a07 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ -/* global Flash */ + /* global Flash */ +import CreateMergeRequestDropdown from './create_merge_request_dropdown'; require('./flash'); require('~/lib/utils/text_utility'); @@ -18,48 +19,49 @@ class Issue { document.querySelector('#task_status_short').innerText = result.task_status_short; } }); - Issue.initIssueBtnEventListeners(); + this.initIssueBtnEventListeners(); } Issue.$btnNewBranch = $('#new-branch'); + Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); Issue.initMergeRequests(); Issue.initRelatedBranches(); - Issue.initCanCreateBranch(); + + if (Issue.createMrDropdownWrap) { + this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap); + } } - static initIssueBtnEventListeners() { + initIssueBtnEventListeners() { const issueFailMessage = 'Unable to update this issue at this time.'; - const closeButtons = $('a.btn-close'); const isClosedBadge = $('div.status-box-closed'); const isOpenBadge = $('div.status-box-open'); const projectIssuesCounter = $('.issue_counter'); const reopenButtons = $('a.btn-reopen'); - return closeButtons.add(reopenButtons).on('click', function(e) { - var $this, shouldSubmit, url; + return closeButtons.add(reopenButtons).on('click', (e) => { + var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); - $this = $(this); - shouldSubmit = $this.hasClass('btn-comment'); + $button = $(e.currentTarget); + shouldSubmit = $button.hasClass('btn-comment'); if (shouldSubmit) { - Issue.submitNoteForm($this.closest('form')); + Issue.submitNoteForm($button.closest('form')); } - $this.prop('disabled', true); - Issue.setNewBranchButtonState(true, null); - url = $this.attr('href'); + $button.prop('disabled', true); + url = $button.attr('href'); return $.ajax({ type: 'PUT', url: url - }).fail(function(jqXHR, textStatus, errorThrown) { - new Flash(issueFailMessage); - Issue.initCanCreateBranch(); - }).done(function(data, textStatus, jqXHR) { + }) + .fail(() => new Flash(issueFailMessage)) + .done((data) => { if ('id' in data) { $(document).trigger('issuable:change'); - const isClosed = $this.hasClass('btn-close'); + const isClosed = $button.hasClass('btn-close'); closeButtons.toggleClass('hidden', isClosed); reopenButtons.toggleClass('hidden', !isClosed); isClosedBadge.toggleClass('hidden', !isClosed); @@ -68,12 +70,21 @@ class Issue { let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues)); + + if (this.createMergeRequestDropdown) { + if (isClosed) { + this.createMergeRequestDropdown.unavailable(); + this.createMergeRequestDropdown.disable(); + } else { + // We should check in case a branch was created in another tab + this.createMergeRequestDropdown.checkAbilityToCreateBranch(); + } + } } else { new Flash(issueFailMessage); } - $this.prop('disabled', false); - Issue.initCanCreateBranch(); + $button.prop('disabled', false); }); }); } @@ -109,29 +120,6 @@ class Issue { } }); } - - static initCanCreateBranch() { - // If the user doesn't have the required permissions the container isn't - // rendered at all. - if (Issue.$btnNewBranch.length === 0) { - return; - } - return $.getJSON(Issue.$btnNewBranch.data('path')).fail(function() { - Issue.setNewBranchButtonState(false, false); - new Flash('Failed to check if a new branch can be created.'); - }).done(function(data) { - Issue.setNewBranchButtonState(false, data.can_create_branch); - }); - } - - static setNewBranchButtonState(isPending, canCreate) { - if (Issue.$btnNewBranch.length === 0) { - return; - } - - Issue.$btnNewBranch.find('.available').toggle(!isPending && canCreate); - Issue.$btnNewBranch.find('.unavailable').toggle(!isPending && !canCreate); - } } export default Issue; diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 38c673e8907..841b24a60a3 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -19,12 +19,10 @@ }); }; - Milestone.sortIssues = function(data) { - var sort_issues_url; - sort_issues_url = location.href + "/sort_issues"; + Milestone.sortIssues = function(url, data) { return $.ajax({ type: "PUT", - url: sort_issues_url, + url, data: data, success: function(_data) { return Milestone.successCallback(_data); @@ -36,12 +34,10 @@ }); }; - Milestone.sortMergeRequests = function(data) { - var sort_mr_url; - sort_mr_url = location.href + "/sort_merge_requests"; + Milestone.sortMergeRequests = function(url, data) { return $.ajax({ type: "PUT", - url: sort_mr_url, + url, data: data, success: function(_data) { return Milestone.successCallback(_data); @@ -81,42 +77,55 @@ }; function Milestone() { - var oldMouseStart; + this.issuesSortEndpoint = $('#tab-issues').data('sort-endpoint'); + this.mergeRequestsSortEndpoint = $('#tab-merge-requests').data('sort-endpoint'); + this.bindIssuesSorting(); - this.bindMergeRequestSorting(); this.bindTabsSwitching(); + + // Load merge request tab if it is active + // merge request tab is active based on different conditions in the backend + this.loadTab($('.js-milestone-tabs .active a')); + + this.loadInitialTab(); } Milestone.prototype.bindIssuesSorting = function() { + if (!this.issuesSortEndpoint) return; + $('#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed').each(function (i, el) { this.createSortable(el, { group: 'issue-list', listEls: $('.issues-sortable-list'), fieldName: 'issue', - sortCallback: Milestone.sortIssues, + sortCallback: (data) => { + Milestone.sortIssues(this.issuesSortEndpoint, data); + }, updateCallback: Milestone.updateIssue, }); }.bind(this)); }; Milestone.prototype.bindTabsSwitching = function() { - return $('a[data-toggle="tab"]').on('show.bs.tab', function(e) { - var currentTabClass, previousTabClass; - currentTabClass = $(e.target).data('show'); - previousTabClass = $(e.relatedTarget).data('show'); - $(previousTabClass).hide(); - $(currentTabClass).removeClass('hidden'); - return $(currentTabClass).show(); + return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => { + const $target = $(e.target); + + location.hash = $target.attr('href'); + this.loadTab($target); }); }; Milestone.prototype.bindMergeRequestSorting = function() { + if (!this.mergeRequestsSortEndpoint) return; + $("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").each(function (i, el) { this.createSortable(el, { group: 'merge-request-list', listEls: $(".merge_requests-sortable-list:not(#merge_requests-list-merged)"), fieldName: 'merge_request', - sortCallback: Milestone.sortMergeRequests, + sortCallback: (data) => { + Milestone.sortMergeRequests(this.mergeRequestsSortEndpoint, data); + }, updateCallback: Milestone.updateMergeRequest, }); }.bind(this)); @@ -169,6 +178,35 @@ }); }; + Milestone.prototype.loadInitialTab = function() { + const $target = $(`.js-milestone-tabs a[href="${location.hash}"]`); + + if ($target.length) { + $target.tab('show'); + } + }; + + Milestone.prototype.loadTab = function($target) { + const endpoint = $target.data('endpoint'); + const tabElId = $target.attr('href'); + + if (endpoint && !$target.hasClass('is-loaded')) { + $.ajax({ + url: endpoint, + dataType: 'JSON', + }) + .fail(() => new Flash('Error loading milestone tab')) + .done((data) => { + $(tabElId).html(data.html); + $target.addClass('is-loaded'); + + if (tabElId === '#tab-merge-requests') { + this.bindMergeRequestSorting(); + } + }); + } + }; + return Milestone; })(); }).call(window); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 974fb0d83da..87f03a40eba 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -4,6 +4,7 @@ /* global ResolveService */ /* global mrRefreshWidgetUrl */ +import $ from 'jquery'; import Cookies from 'js-cookie'; import CommentTypeToggle from './comment_type_toggle'; @@ -16,6 +17,10 @@ require('vendor/jquery.caret'); // required by jquery.atwho require('vendor/jquery.atwho'); require('./task_list'); +const normalizeNewlines = function(str) { + return str.replace(/\r\n/g, '\n'); +}; + (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; @@ -42,13 +47,17 @@ require('./task_list'); this.refresh = bind(this.refresh, this); this.keydownNoteText = bind(this.keydownNoteText, this); this.toggleCommitList = bind(this.toggleCommitList, this); + this.notes_url = notes_url; this.note_ids = note_ids; + // Used to keep track of updated notes while people are editing things + this.updatedNotesTrackingMap = {}; this.last_fetched_at = last_fetched_at; this.noteable_url = document.URL; this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge")); this.basePollingInterval = 15000; this.maxPollingSteps = 4; + this.cleanBinding(); this.addBinding(); this.setPollingInterval(); @@ -128,7 +137,7 @@ require('./task_list'); $(document).off("click", ".js-discussion-reply-button"); $(document).off("click", ".js-add-diff-note-button"); $(document).off("visibilitychange"); - $(document).off("keyup", ".js-note-text"); + $(document).off("keyup input", ".js-note-text"); $(document).off("click", ".js-note-target-reopen"); $(document).off("click", ".js-note-target-close"); $(document).off("click", ".js-note-discard"); @@ -267,20 +276,20 @@ require('./task_list'); return this.initRefresh(); }; - Notes.prototype.handleCreateChanges = function(note) { + Notes.prototype.handleCreateChanges = function(noteEntity) { var votesBlock; - if (typeof note === 'undefined') { + if (typeof noteEntity === 'undefined') { return; } - if (note.commands_changes) { - if ('merge' in note.commands_changes) { + if (noteEntity.commands_changes) { + if ('merge' in noteEntity.commands_changes) { $.get(mrRefreshWidgetUrl); } - if ('emoji_award' in note.commands_changes) { + if ('emoji_award' in noteEntity.commands_changes) { votesBlock = $('.js-awards-block').eq(0); - gl.awardsHandler.addAwardToEmojiBar(votesBlock, note.commands_changes.emoji_award); + gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); return gl.awardsHandler.scrollToAwards(); } } @@ -292,41 +301,76 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderNote = function(note, $form) { - var $notesList; - if (note.discussion_html != null) { - return this.renderDiscussionNote(note, $form); + Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) { + if (noteEntity.discussion_html != null) { + return this.renderDiscussionNote(noteEntity, $form); } - if (!note.valid) { - if (note.errors.commands_only) { - new Flash(note.errors.commands_only, 'notice', this.parentTimeline); + if (!noteEntity.valid) { + if (noteEntity.errors.commands_only) { + new Flash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); this.refresh(); } return; } - if (this.isNewNote(note)) { - this.note_ids.push(note.id); + const $note = $notesList.find(`#note_${noteEntity.id}`); + if (this.isNewNote(noteEntity)) { + this.note_ids.push(noteEntity.id); - $notesList = window.$('ul.main-notes-list'); - Notes.animateAppendNote(note.html, $notesList); + const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); // Update datetime format on the recent note - gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); + gl.utils.localTimeAgo($newNote.find('.js-timeago'), false); this.collapseLongCommitList(); this.taskList.init(); this.refresh(); return this.updateNotesCount(1); } + // The server can send the same update multiple times so we need to make sure to only update once per actual update. + else if (this.isUpdatedNote(noteEntity, $note)) { + const isEditing = $note.hasClass('is-editing'); + const initialContent = normalizeNewlines( + $note.find('.original-note-content').text().trim() + ); + const $textarea = $note.find('.js-note-text'); + const currentContent = $textarea.val(); + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteNote = normalizeNewlines(noteEntity.note); + const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote; + + if (isEditing && isTextareaUntouched) { + $textarea.val(noteEntity.note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; + } + else if (isEditing && !isTextareaUntouched) { + this.putConflictEditWarningInPlace(noteEntity, $note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; + } + else { + const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); + + // Update datetime format on the recent note + gl.utils.localTimeAgo($updatedNote.find('.js-timeago'), false); + } + } }; /* Check if note does not exists on page */ - Notes.prototype.isNewNote = function(note) { - return $.inArray(note.id, this.note_ids) === -1; + Notes.prototype.isNewNote = function(noteEntity) { + return $.inArray(noteEntity.id, this.note_ids) === -1; + }; + + Notes.prototype.isUpdatedNote = function(noteEntity, $note) { + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteNote = normalizeNewlines(noteEntity.note); + const currentNoteText = normalizeNewlines( + $note.find('.original-note-content').text().trim() + ); + return sanitizedNoteNote !== currentNoteText; }; Notes.prototype.isParallelView = function() { @@ -339,31 +383,31 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderDiscussionNote = function(note, $form) { + Notes.prototype.renderDiscussionNote = function(noteEntity, $form) { var discussionContainer, form, row, lineType, diffAvatarContainer; - if (!this.isNewNote(note)) { + if (!this.isNewNote(noteEntity)) { return; } - this.note_ids.push(note.id); - form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.discussion_id + "']"); + this.note_ids.push(noteEntity.id); + form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']"); row = form.closest("tr"); lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? - discussionContainer = window.$(`.notes[data-discussion-id="${note.discussion_id}"]`); + discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); if (!discussionContainer.length) { discussionContainer = form.closest('.discussion').find('.notes'); } if (discussionContainer.length === 0) { - if (note.diff_discussion_html) { - var $discussion = $(note.diff_discussion_html).renderGFM(); + if (noteEntity.diff_discussion_html) { + var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) { // insert the note and the reply button after the temp row row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]'); + var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]'); var contentContainerClass = '.' + $notes.closest('.notes_content') .attr('class') .split(' ') @@ -373,17 +417,18 @@ require('./task_list'); } } // Init discussion on 'Discussion' page if it is merge request page - if (window.$('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) { - Notes.animateAppendNote(note.discussion_html, window.$('ul.main-notes-list')); + const page = $('body').attr('data-page'); + if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } } else { // append new note to all matching discussions - Notes.animateAppendNote(note.html, discussionContainer); + Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { gl.diffNotesCompileComponents(); - this.renderDiscussionAvatar(diffAvatarContainer, note); + this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); } gl.utils.localTimeAgo($('.js-timeago'), false); @@ -397,13 +442,13 @@ require('./task_list'); .get(0); }; - Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, note) { + Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, noteEntity) { var commentButton = diffAvatarContainer.find('.js-add-diff-note-button'); var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); if (!avatarHolder.length) { avatarHolder = document.createElement('diff-note-avatars'); - avatarHolder.setAttribute('discussion-id', note.discussion_id); + avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id); diffAvatarContainer.append(avatarHolder); @@ -550,16 +595,16 @@ require('./task_list'); Updates the current note field. */ - Notes.prototype.updateNote = function(_xhr, note, _status) { + Notes.prototype.updateNote = function(_xhr, noteEntity, _status) { var $html, $note_li; // Convert returned HTML to a jQuery object so we can modify it further - $html = $(note.html); + $html = $(noteEntity.html); this.revertNoteEditForm(); gl.utils.localTimeAgo($('.js-timeago', $html)); $html.renderGFM(); $html.find('.js-task-list-container').taskList('enable'); // Find the note's `li` element by ID and replace it with the updated HTML - $note_li = $('.note-row-' + note.id); + $note_li = $('.note-row-' + noteEntity.id); $note_li.replaceWith($html); @@ -570,7 +615,7 @@ require('./task_list'); Notes.prototype.checkContentToAllowEditing = function($el) { var initialContent = $el.find('.original-note-content').text().trim(); - var currentContent = $el.find('.note-textarea').val(); + var currentContent = $el.find('.js-note-text').val(); var isAllowed = true; if (currentContent === initialContent) { @@ -584,7 +629,7 @@ require('./task_list'); gl.utils.scrollToElement($el); } - $el.find('.js-edit-warning').show(); + $el.find('.js-finish-edit-warning').show(); isAllowed = false; } @@ -603,7 +648,7 @@ require('./task_list'); var $target = $(e.target); var $editForm = $(this.getEditFormSelector($target)); var $note = $target.closest('.note'); - var $currentlyEditing = $('.note.is-editting:visible'); + var $currentlyEditing = $('.note.is-editing:visible'); if ($currentlyEditing.length) { var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); @@ -615,7 +660,7 @@ require('./task_list'); $note.find('.js-note-attachment-delete').show(); $editForm.addClass('current-note-edit-form'); - $note.addClass('is-editting'); + $note.addClass('is-editing'); this.putEditFormInPlace($target); }; @@ -627,21 +672,34 @@ require('./task_list'); Notes.prototype.cancelEdit = function(e) { e.preventDefault(); - var $target = $(e.target); - var note = $target.closest('.note'); - note.find('.js-edit-warning').hide(); + const $target = $(e.target); + const $note = $target.closest('.note'); + const noteId = $note.attr('data-note-id'); + this.revertNoteEditForm($target); - return this.removeNoteEditForm(note); + + if (this.updatedNotesTrackingMap[noteId]) { + const $newNote = $(this.updatedNotesTrackingMap[noteId].html); + $note.replaceWith($newNote); + this.updatedNotesTrackingMap[noteId] = null; + + // Update datetime format on the recent note + gl.utils.localTimeAgo($newNote.find('.js-timeago'), false); + } + else { + $note.find('.js-finish-edit-warning').hide(); + this.removeNoteEditForm($note); + } }; Notes.prototype.revertNoteEditForm = function($target) { - $target = $target || $('.note.is-editting:visible'); + $target = $target || $('.note.is-editing:visible'); var selector = this.getEditFormSelector($target); var $editForm = $(selector); $editForm.insertBefore('.notes-form'); $editForm.find('.js-comment-button').enable(); - $editForm.find('.js-edit-warning').hide(); + $editForm.find('.js-finish-edit-warning').hide(); }; Notes.prototype.getEditFormSelector = function($el) { @@ -654,11 +712,11 @@ require('./task_list'); return selector; }; - Notes.prototype.removeNoteEditForm = function(note) { - var form = note.find('.current-note-edit-form'); - note.removeClass('is-editting'); + Notes.prototype.removeNoteEditForm = function($note) { + var form = $note.find('.current-note-edit-form'); + $note.removeClass('is-editing'); form.removeClass('current-note-edit-form'); - form.find('.js-edit-warning').hide(); + form.find('.js-finish-edit-warning').hide(); // Replace markdown textarea text with original note text. return form.find('.js-note-text').val(form.find('form.edit-note').data('original-note')); }; @@ -683,9 +741,9 @@ require('./task_list'); // to remove all. Using $(".note[id='noteId']") ensure we get all the notes, // where $("#noteId") would return only one. return function(i, el) { - var note, notes; - note = $(el); - notes = note.closest(".discussion-notes"); + var $note, $notes; + $note = $(el); + $notes = $note.closest(".discussion-notes"); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -693,18 +751,18 @@ require('./task_list'); } } - note.remove(); + $note.remove(); // check if this is the last note for this line - if (notes.find(".note").length === 0) { - var notesTr = notes.closest("tr"); + if ($notes.find(".note").length === 0) { + var notesTr = $notes.closest("tr"); // "Discussions" tab - notes.closest(".timeline-entry").remove(); + $notes.closest(".timeline-entry").remove(); // The notes tr can contain multiple lists of notes, like on the parallel diff if (notesTr.find('.discussion-notes').length > 1) { - notes.remove(); + $notes.remove(); } else { notesTr.remove(); } @@ -723,12 +781,11 @@ require('./task_list'); */ Notes.prototype.removeAttachment = function() { - var note; - note = $(this).closest(".note"); - note.find(".note-attachment").remove(); - note.find(".note-body > .note-text").show(); - note.find(".note-header").show(); - return note.find(".current-note-edit-form").remove(); + const $note = $(this).closest(".note"); + $note.find(".note-attachment").remove(); + $note.find(".note-body > .note-text").show(); + $note.find(".note-header").show(); + return $note.find(".current-note-edit-form").remove(); }; /* @@ -1004,6 +1061,19 @@ require('./task_list'); $editForm.find('.referenced-users').hide(); }; + Notes.prototype.putConflictEditWarningInPlace = function(noteEntity, $note) { + if ($note.find('.js-conflict-edit-warning').length === 0) { + const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> + This comment has changed since you started editing, please review the + <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer"> + updated comment + </a> + to ensure information is not lost + </div>`); + $alert.insertAfter($note.find('.note-text')); + } + }; + Notes.prototype.updateNotesCount = function(updateCount) { return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); }; @@ -1064,11 +1134,20 @@ require('./task_list'); return $form; }; - Notes.animateAppendNote = function(noteHTML, $notesList) { - const $note = window.$(noteHTML); + Notes.animateAppendNote = function(noteHtml, $notesList) { + const $note = $(noteHtml); $note.addClass('fade-in').renderGFM(); $notesList.append($note); + return $note; + }; + + Notes.animateUpdateNote = function(noteHtml, $note) { + const $updatedNote = $(noteHtml); + + $updatedNote.addClass('fade-in').renderGFM(); + $note.replaceWith($updatedNote); + return $updatedNote; }; return Notes; diff --git a/app/assets/javascripts/pipelines/components/stage.js b/app/assets/javascripts/pipelines/components/stage.js deleted file mode 100644 index 203485f2990..00000000000 --- a/app/assets/javascripts/pipelines/components/stage.js +++ /dev/null @@ -1,115 +0,0 @@ -/* global Flash */ -import StatusIconEntityMap from '../../ci_status_icons'; - -export default { - props: { - stage: { - type: Object, - required: true, - }, - }, - - data() { - return { - builds: '', - spinner: '<span class="fa fa-spinner fa-spin"></span>', - }; - }, - - updated() { - if (this.builds) { - this.stopDropdownClickPropagation(); - } - }, - - methods: { - fetchBuilds(e) { - const ariaExpanded = e.currentTarget.attributes['aria-expanded']; - - if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null; - - return this.$http.get(this.stage.dropdown_path) - .then((response) => { - this.builds = JSON.parse(response.body).html; - }) - .catch(() => { - // If dropdown is opened we'll close it. - if (this.$el.classList.contains('open')) { - $(this.$refs.dropdown).dropdown('toggle'); - } - - const flash = new Flash('Something went wrong on our end.'); - return flash; - }); - }, - - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')) - .on('click', (e) => { - e.stopPropagation(); - }); - }, - }, - computed: { - buildsOrSpinner() { - return this.builds ? this.builds : this.spinner; - }, - dropdownClass() { - if (this.builds) return 'js-builds-dropdown-container'; - return 'js-builds-dropdown-loading builds-dropdown-loading'; - }, - buildStatus() { - return `Build: ${this.stage.status.label}`; - }, - tooltip() { - return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; - }, - triggerButtonClass() { - return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; - }, - svgHTML() { - return StatusIconEntityMap[this.stage.status.icon]; - }, - }, - template: ` - <div> - <button - @click="fetchBuilds($event)" - :class="triggerButtonClass" - :title="stage.title" - data-placement="top" - data-toggle="dropdown" - type="button" - :aria-label="stage.title" - ref="dropdown"> - <span - v-html="svgHTML" - aria-hidden="true"> - </span> - <i - class="fa fa-caret-down" - aria-hidden="true" /> - </button> - <ul - ref="dropdown-content" - class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> - <div - class="arrow-up" - aria-hidden="true"></div> - <div - :class="dropdownClass" - class="js-builds-dropdown-list scrollable-menu" - v-html="buildsOrSpinner"> - </div> - </ul> - </div> - `, -}; diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue new file mode 100644 index 00000000000..2e485f951a1 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -0,0 +1,173 @@ +<script> + +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +/* global Flash */ +import StatusIconEntityMap from '../../ci_status_icons'; + +export default { + props: { + stage: { + type: Object, + required: true, + }, + + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + isLoading: false, + dropdownContent: '', + endpoint: this.stage.dropdown_path, + }; + }, + + updated() { + if (this.dropdownContent.length > 0) { + this.stopDropdownClickPropagation(); + } + }, + + watch: { + updateDropdown() { + if (this.updateDropdown && + this.isDropdownOpen() && + !this.isLoading) { + this.fetchJobs(); + } + }, + }, + + methods: { + onClickStage() { + if (!this.isDropdownOpen()) { + this.isLoading = true; + this.fetchJobs(); + } + }, + + fetchJobs() { + this.$http.get(this.endpoint) + .then((response) => { + this.dropdownContent = response.json().html; + this.isLoading = false; + }) + .catch(() => { + this.closeDropdown(); + this.isLoading = false; + + const flash = new Flash('Something went wrong on our end.'); + return flash; + }); + }, + + /** + * When the user right clicks or cmd/ctrl + click in the job name + * the dropdown should not be closed and the link should open in another tab, + * so we stop propagation of the click event inside the dropdown. + * + * Since this component is rendered multiple times per page we need to guarantee we only + * target the click event of this component. + */ + stopDropdownClickPropagation() { + $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')) + .on('click', (e) => { + e.stopPropagation(); + }); + }, + + closeDropdown() { + if (this.isDropdownOpen()) { + $(this.$refs.dropdown).dropdown('toggle'); + } + }, + + isDropdownOpen() { + return this.$el.classList.contains('open'); + }, + }, + + computed: { + dropdownClass() { + return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading'; + }, + + triggerButtonClass() { + return `ci-status-icon-${this.stage.status.group}`; + }, + + svgIcon() { + return StatusIconEntityMap[this.stage.status.icon]; + }, + }, +}; +</script> + +<template> + <div class="dropdown"> + <button + :class="triggerButtonClass" + @click="onClickStage" + class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button" + :title="stage.title" + data-placement="top" + data-toggle="dropdown" + type="button" + id="stageDropdown" + aria-haspopup="true" + aria-expanded="false"> + + <span + v-html="svgIcon" + aria-hidden="true" + :aria-label="stage.title"> + </span> + + <i + class="fa fa-caret-down" + aria-hidden="true"> + </i> + </button> + + <ul + class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" + aria-labelledby="stageDropdown"> + + <li + :class="dropdownClass" + class="js-builds-dropdown-list scrollable-menu"> + + <div + class="text-center" + v-if="isLoading"> + <i + class="fa fa-spin fa-spinner" + aria-hidden="true" + aria-label="Loading"> + </i> + </div> + + <ul + v-else + v-html="dropdownContent"> + </ul> + </li> + </ul> + </div> +</script> diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 93d4818231f..934bd7deb31 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -49,6 +49,7 @@ export default { isLoading: false, hasError: false, isMakingRequest: false, + updateGraphDropdown: false, }; }, @@ -198,15 +199,21 @@ export default { this.store.storePagination(response.headers); this.isLoading = false; + this.updateGraphDropdown = true; }, errorCallback() { this.hasError = true; this.isLoading = false; + this.updateGraphDropdown = false; }, setIsMakingRequest(isMakingRequest) { this.isMakingRequest = isMakingRequest; + + if (isMakingRequest) { + this.updateGraphDropdown = false; + } }, }, @@ -263,7 +270,9 @@ export default { <pipelines-table-component :pipelines="state.pipelines" - :service="service"/> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </div> <gl-pagination diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js b/app/assets/javascripts/vue_shared/components/pipelines_table.js index afd8d7acf6b..48a39f18112 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js @@ -10,13 +10,18 @@ export default { pipelines: { type: Array, required: true, - default: () => ([]), }, service: { type: Object, required: true, }, + + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, + }, }, components: { @@ -40,7 +45,9 @@ export default { v-bind:model="model"> <tr is="pipelines-table-row-component" :pipeline="model" - :service="service"></tr> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </template> </tbody> </table> diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index 79806bc7204..fbae85c85f6 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -3,7 +3,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue'; import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions'; import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; import PipelinesStatusComponent from '../../pipelines/components/status'; -import PipelinesStageComponent from '../../pipelines/components/stage'; +import PipelinesStageComponent from '../../pipelines/components/stage.vue'; import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; import CommitComponent from './commit'; @@ -24,6 +24,12 @@ export default { type: Object, required: true, }, + + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, + }, }, components: { @@ -213,7 +219,10 @@ export default { <div class="stage-container dropdown js-mini-pipeline-graph" v-if="pipeline.details.stages.length > 0" v-for="stage in pipeline.details.stages"> - <dropdown-stage :stage="stage"/> + + <dropdown-stage + :stage="stage" + :update-dropdown="updateGraphDropdown"/> </div> </td> diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 1b4694377b3..feefaad8a15 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -425,12 +425,6 @@ float: right; } -.diffs { - .content-block { - border-bottom: none; - } -} - .files-changed { border-bottom: none; } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 97fab513b01..ad6eb9f6fe0 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -6,7 +6,13 @@ } .limit-container-width { - .detail-page-header { + .detail-page-header, + .page-content-header, + .commit-box, + .info-well, + .notes, + .commit-ci-menu, + .files-changed { @extend .fixed-width-container; } @@ -36,8 +42,7 @@ } .diffs { - .mr-version-controls, - .files-changed { + .mr-version-controls { @extend .fixed-width-container; } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 2aa52986e0a..b18bbc329c3 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -161,3 +161,86 @@ ul.related-merge-requests > li { .recaptcha { margin-bottom: 30px; } + +.new-branch-col { + padding-top: 10px; +} + +.create-mr-dropdown-wrap { + .btn-group:not(.hide) { + display: flex; + } + + .js-create-merge-request { + flex-grow: 1; + flex-shrink: 0; + } + + .dropdown-menu { + width: 300px; + opacity: 1; + visibility: visible; + transform: translateY(0); + display: none; + } + + .dropdown-toggle { + .fa-caret-down { + pointer-events: none; + margin-left: 0; + color: inherit; + margin-left: 0; + } + } + + li:not(.divider) { + padding: 6px; + cursor: pointer; + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + } + + &.droplab-item-selected { + .icon-container { + i { + visibility: visible; + } + } + } + + .icon-container { + float: left; + padding-left: 6px; + + i { + visibility: hidden; + } + } + + .description { + padding-left: 30px; + font-size: 13px; + + strong { + display: block; + font-weight: 600; + } + } + } +} + +@media (min-width: $screen-sm-min) { + .new-branch-col { + padding-top: 0; + text-align: right; + } + + .create-mr-dropdown-wrap { + .btn-group:not(.hide) { + display: inline-block; + } + } +} diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index be7193bae04..8dbac76e30a 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -133,3 +133,55 @@ right: 160px; } } + +.flex-project-members-panel { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + @media (max-width: $screen-sm-min) { + display: block; + + .flex-project-title { + vertical-align: top; + display: inline-block; + max-width: 90%; + } + } + + .flex-project-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .badge { + height: 17px; + line-height: 16px; + margin-right: 5px; + padding-top: 1px; + padding-bottom: 1px; + } + + .flex-project-members-form { + flex-wrap: nowrap; + white-space: nowrap; + margin-left: auto; + } +} + +.panel { + .panel-heading { + .badge { + margin-top: 0; + } + + @media (max-width: $screen-sm-min) { + .badge { + margin-right: 0; + margin-left: 0; + } + } + } +}
\ No newline at end of file diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6a419384a34..72660113e3c 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -482,6 +482,10 @@ } } +.target-branch-select-dropdown-container { + position: relative; +} + .assign-to-me-link { padding-left: 12px; white-space: nowrap; @@ -511,7 +515,6 @@ .mr-version-controls { background: $gray-light; - border-bottom: 1px solid $border-color; color: $gl-text-color; .mr-version-menus-container { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 7cf74502a3a..f89150ebead 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -67,7 +67,7 @@ ul.notes { } } - &.is-editting { + &.is-editing { .note-header, .note-text, .edited-text { diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a4fe652b52f..9115d26c779 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -781,16 +781,11 @@ } .scrollable-menu { + padding: 0; max-height: 245px; overflow: auto; } - // Loading icon - .builds-dropdown-loading { - margin: 0 auto; - width: 20px; - } - // Action icon on the right a.ci-action-icon-wrapper { color: $action-icon-color; @@ -893,30 +888,29 @@ * Top arrow in the dropdown in the mini pipeline graph */ .mini-pipeline-graph-dropdown-menu { - .arrow-up { - &::before, - &::after { - content: ''; - display: inline-block; - position: absolute; - width: 0; - height: 0; - border-color: transparent; - border-style: solid; - top: -6px; - left: 2px; - border-width: 0 5px 6px; - } - &::before { - border-width: 0 5px 5px; - border-bottom-color: $border-color; - } + &::before, + &::after { + content: ''; + display: inline-block; + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + top: -6px; + left: 2px; + border-width: 0 5px 6px; + } - &::after { - margin-top: 1px; - border-bottom-color: $white-light; - } + &::before { + border-width: 0 5px 5px; + border-bottom-color: $border-color; + } + + &::after { + margin-top: 1px; + border-bottom-color: $white-light; } } diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 04ff2d52b91..b64b89485f7 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -71,7 +71,6 @@ .nav-controls { width: auto; min-width: 50%; - white-space: nowrap; } } diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb new file mode 100644 index 00000000000..3e2a0fe4f8b --- /dev/null +++ b/app/controllers/concerns/milestone_actions.rb @@ -0,0 +1,53 @@ +module MilestoneActions + extend ActiveSupport::Concern + + def merge_requests + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_merge_requests_tab", { + merge_requests: @milestone.merge_requests, + show_project_name: true + }) + end + end + end + + def participants + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_participants_tab", { + users: @milestone.participants + }) + end + end + end + + def labels + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_labels_tab", { + labels: @milestone.labels + }) + end + end + end + + private + + def tabs_json(partial, data = {}) + { + html: view_to_html_string(partial, data) + } + end + + def milestone_redirect_path + if @project + namespace_project_milestone_path(@project.namespace, @project, @milestone) + else + group_milestone_path(@group, @milestone.safe_title, title: @milestone.title) + end + end +end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb new file mode 100644 index 00000000000..dec2e27335a --- /dev/null +++ b/app/controllers/concerns/uploads_actions.rb @@ -0,0 +1,27 @@ +module UploadsActions + def create + link_to_file = UploadService.new(model, params[:file], uploader_class).execute + + respond_to do |format| + if link_to_file + format.json do + render json: { link: link_to_file } + end + else + format.json do + render json: 'Invalid file.', status: :unprocessable_entity + end + end + end + end + + def show + return render_404 unless uploader.exists? + + disposition = uploader.image_or_video? ? 'inline' : 'attachment' + + expires_in 0.seconds, must_revalidate: true, private: true + + send_file uploader.file.path, disposition: disposition + end +end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 43102596201..e52fa766044 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -1,6 +1,8 @@ class Groups::MilestonesController < Groups::ApplicationController + include MilestoneActions + before_action :group_projects - before_action :milestone, only: [:show, :update] + before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels] before_action :authorize_admin_milestones!, only: [:new, :create, :update] def index diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index e2f81b09adc..89f1128ec36 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -89,4 +89,8 @@ class Projects::ApplicationController < ApplicationController def builds_enabled return render_404 unless @project.feature_available?(:builds, current_user) end + + def require_pages_enabled! + not_found unless Gitlab.config.pages.enabled + end end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 59222637961..1224e9503c9 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,11 +1,13 @@ class Projects::ArtifactsController < Projects::ApplicationController include ExtractsPath + include RendersBlob layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path before_action :validate_artifacts! + before_action :set_path_and_entry, only: [:file, :raw] def download if artifacts_file.file_storage? @@ -16,22 +18,32 @@ class Projects::ArtifactsController < Projects::ApplicationController end def browse - directory = params[:path] ? "#{params[:path]}/" : '' + @path = params[:path] + directory = @path ? "#{@path}/" : '' @entry = build.artifacts_metadata_entry(directory) render_404 unless @entry.exists? end def file - entry = build.artifacts_metadata_entry(params[:path]) + blob = @entry.blob + override_max_blob_size(blob) - if entry.exists? - send_artifacts_entry(build, entry) - else - render_404 + respond_to do |format| + format.html do + render 'file' + end + + format.json do + render_blob_json(blob) + end end end + def raw + send_artifacts_entry(build, @entry) + end + def keep build.keep_artifacts! redirect_to namespace_project_build_path(project.namespace, project, build) @@ -60,7 +72,10 @@ class Projects::ArtifactsController < Projects::ApplicationController end def build - @build ||= build_from_id || build_from_ref + @build ||= begin + build = build_from_id || build_from_ref + build&.present(current_user: current_user) + end end def build_from_id @@ -77,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController def artifacts_file @artifacts_file ||= build.artifacts_file end + + def set_path_and_entry + @path = params[:path] + @entry = build.artifacts_metadata_entry(@path) + + render_404 unless @entry.exists? + end end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 840405f38cb..f0f031303d8 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -46,20 +46,28 @@ class Projects::BranchesController < Projects::ApplicationController SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue end - if result[:status] == :success - @branch = result[:branch] - - if redirect_to_autodeploy - redirect_to( - url_to_autodeploy_setup(project, branch_name), - notice: view_context.autodeploy_flash_notice(branch_name)) - else - redirect_to namespace_project_tree_path(@project.namespace, @project, - @branch.name) + respond_to do |format| + format.html do + if result[:status] == :success + if redirect_to_autodeploy + redirect_to url_to_autodeploy_setup(project, branch_name), + notice: view_context.autodeploy_flash_notice(branch_name) + else + redirect_to namespace_project_tree_path(@project.namespace, @project, branch_name) + end + else + @error = result[:message] + render action: 'new' + end + end + + format.json do + if result[:status] == :success + render json: { name: branch_name, url: namespace_project_tree_url(@project.namespace, @project, branch_name) } + else + render json: result[:messsage], status: :unprocessable_entity + end end - else - @error = result[:message] - render action: 'new' end end diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index d0c44e297e3..f27089b8590 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController layout "project_settings" def index - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json do + render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json + end + end end def new @@ -19,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController @key = DeployKey.new(deploy_key_params.merge(user: current_user)) unless @key.valid? && @project.deploy_keys << @key - flash[:alert] = @key.errors.full_messages.join(', ').html_safe + flash[:alert] = @key.errors.full_messages.join(', ').html_safe end redirect_to_repository_settings(@project) end @@ -27,7 +32,10 @@ class Projects::DeployKeysController < Projects::ApplicationController def enable Projects::EnableDeployKeyService.new(@project, current_user, params).execute - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end def disable @@ -35,7 +43,11 @@ class Projects::DeployKeysController < Projects::ApplicationController return render_404 unless deploy_key_project deploy_key_project.destroy! - redirect_to_repository_settings(@project) + + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end protected diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index cbf67137261..af9157bfbb5 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, - :related_branches, :can_create_branch, :rendered_title] + :related_branches, :can_create_branch, :rendered_title, :create_merge_request] # Allow read any issue before_action :authorize_read_issue!, only: [:show, :rendered_title] @@ -22,6 +22,9 @@ class Projects::IssuesController < Projects::ApplicationController # Allow modify issue before_action :authorize_update_issue!, only: [:edit, :update] + # Allow create a new branch and empty WIP merge request from current issue + before_action :authorize_create_merge_request!, only: [:create_merge_request] + respond_to :html def index @@ -191,7 +194,7 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.json do - render json: { can_create_branch: can_create } + render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? } end end end @@ -201,6 +204,16 @@ class Projects::IssuesController < Projects::ApplicationController render json: { title: view_context.markdown_field(@issue, :title) } end + def create_merge_request + result = MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute + + if result[:status] == :success + render json: MergeRequestCreateSerializer.new.represent(result[:merge_request]) + else + render json: result[:messsage], status: :unprocessable_entity + end + end + protected def issue @@ -224,6 +237,10 @@ class Projects::IssuesController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_issue, @project) end + def authorize_create_merge_request! + return render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) + end + def module_enabled return render_404 unless @project.feature_available?(:issues, current_user) && @project.default_issues_tracker? end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 09dc8b38229..a63b7ff0bed 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -120,7 +120,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController define_diff_comment_vars else build_merge_request - @diffs = @merge_request.diffs(diff_options) + @compare = @merge_request + @diffs = @compare.diffs(diff_options) @diff_notes_disabled = true end @@ -584,12 +585,14 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - @diffs = + @compare = if @start_sha - @merge_request_diff.compare_with(@start_sha).diffs(diff_options) + @merge_request_diff.compare_with(@start_sha) else - @merge_request_diff.diffs(diff_options) + @merge_request_diff end + + @diffs = @compare.diffs(diff_options) end def define_diff_comment_vars @@ -598,11 +601,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController noteable_id: @merge_request.id } - @diff_notes_disabled = !@merge_request_diff.latest? || @start_sha + @diff_notes_disabled = false @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? - @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@merge_request_diff.diff_refs) + @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs) @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index d0dd524c484..c56bce19eee 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -1,12 +1,14 @@ class Projects::MilestonesController < Projects::ApplicationController + include MilestoneActions + before_action :module_enabled - before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests] + before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests, :merge_requests, :participants, :labels] # Allow read any milestone before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show] + before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] respond_to :html diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index fbd18b68141..93b2c180810 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -1,6 +1,7 @@ class Projects::PagesController < Projects::ApplicationController layout 'project_settings' + before_action :require_pages_enabled! before_action :authorize_read_pages!, only: [:show] before_action :authorize_update_pages!, except: [:show] diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index b8c253f6ae3..3a93977fd27 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -1,6 +1,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController layout 'project_settings' + before_action :require_pages_enabled! before_action :authorize_update_pages!, except: [:show] before_action :domain, only: [:show, :destroy] diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 1780cc0233c..454b8ee17af 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -9,19 +9,19 @@ class Projects::PipelinesController < Projects::ApplicationController def index @scope = params[:scope] @pipelines = PipelinesFinder - .new(project) - .execute(scope: @scope) + .new(project, scope: @scope) + .execute .page(params[:page]) .per(30) @running_count = PipelinesFinder - .new(project).execute(scope: 'running').count + .new(project, scope: 'running').execute.count @pending_count = PipelinesFinder - .new(project).execute(scope: 'pending').count + .new(project, scope: 'pending').execute.count @finished_count = PipelinesFinder - .new(project).execute(scope: 'finished').count + .new(project, scope: 'finished').execute.count @pipelines_count = PipelinesFinder .new(project).execute.count diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index a0b08ad130f..a02cc477e08 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController return if cached_blob? - if @blob.valid_lfs_pointer? + if @blob.stored_externally? send_lfs_object else send_git_blob @repository, @blob diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index 61686499bd3..6966a7c5fee 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -1,33 +1,11 @@ class Projects::UploadsController < Projects::ApplicationController + include UploadsActions + skip_before_action :project, :repository, if: -> { action_name == 'show' && image_or_video? } before_action :authorize_upload_file!, only: [:create] - def create - link_to_file = ::Projects::UploadService.new(project, params[:file]). - execute - - respond_to do |format| - if link_to_file - format.json do - render json: { link: link_to_file } - end - else - format.json do - render json: 'Invalid file.', status: :unprocessable_entity - end - end - end - end - - def show - return render_404 if uploader.nil? || !uploader.file.exists? - - disposition = uploader.image_or_video? ? 'inline' : 'attachment' - send_file uploader.file.path, disposition: disposition - end - private def uploader @@ -52,4 +30,10 @@ class Projects::UploadsController < Projects::ApplicationController def image_or_video? uploader && uploader.file.exists? && uploader.image_or_video? end + + def uploader_class + FileUploader + end + + alias_method :model, :project end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index f1bfd574f04..21a964fb391 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -1,50 +1,43 @@ class UploadsController < ApplicationController - skip_before_action :authenticate_user! - before_action :find_model, :authorize_access! - - def show - uploader = @model.send(upload_mount) - - unless uploader.file_storage? - return redirect_to uploader.url - end + include UploadsActions - unless uploader.file && uploader.file.exists? - return render_404 - end - - disposition = uploader.image? ? 'inline' : 'attachment' - - expires_in 0.seconds, must_revalidate: true, private: true - send_file uploader.file.path, disposition: disposition - end + skip_before_action :authenticate_user! + before_action :find_model + before_action :authorize_access!, only: [:show] + before_action :authorize_create_access!, only: [:create] private def find_model - unless upload_model && upload_mount - return render_404 - end + return render_404 unless upload_model && upload_mount @model = upload_model.find(params[:id]) end def authorize_access! authorized = - case @model - when Project - can?(current_user, :read_project, @model) - when Group - can?(current_user, :read_group, @model) + case model when Note - can?(current_user, :read_project, @model.project) - else - # No authentication required for user avatars. + can?(current_user, :read_project, model.project) + when User true + else + permission = "read_#{model.class.to_s.underscore}".to_sym + + can?(current_user, permission, model) end - return if authorized + render_unauthorized unless authorized + end + + def authorize_create_access! + # for now we support only personal snippets comments + authorized = can?(current_user, :comment_personal_snippet, model) + render_unauthorized unless authorized + end + + def render_unauthorized if current_user render_404 else @@ -58,17 +51,44 @@ class UploadsController < ApplicationController "project" => Project, "note" => Note, "group" => Group, - "appearance" => Appearance + "appearance" => Appearance, + "personal_snippet" => PersonalSnippet } upload_models[params[:model]] end def upload_mount + return true unless params[:mounted_as] + upload_mounts = %w(avatar attachment file logo header_logo) if upload_mounts.include?(params[:mounted_as]) params[:mounted_as] end end + + def uploader + return @uploader if defined?(@uploader) + + if model.is_a?(PersonalSnippet) + @uploader = PersonalFileUploader.new(model, params[:secret]) + + @uploader.retrieve_from_store!(params[:filename]) + else + @uploader = @model.send(upload_mount) + + redirect_to @uploader.url unless @uploader.file_storage? + end + + @uploader + end + + def uploader_class + PersonalFileUploader + end + + def model + @model ||= find_model + end end diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index a9172f6767f..f187a3b61fe 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -1,29 +1,23 @@ class PipelinesFinder - attr_reader :project, :pipelines + attr_reader :project, :pipelines, :params - def initialize(project) + ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze + + def initialize(project, params = {}) @project = project @pipelines = project.pipelines + @params = params end - def execute(scope: nil) - scoped_pipelines = - case scope - when 'running' - pipelines.running - when 'pending' - pipelines.pending - when 'finished' - pipelines.finished - when 'branches' - from_ids(ids_for_ref(branches)) - when 'tags' - from_ids(ids_for_ref(tags)) - else - pipelines - end - - scoped_pipelines.order(id: :desc) + def execute + items = pipelines + items = by_scope(items) + items = by_status(items) + items = by_ref(items) + items = by_name(items) + items = by_username(items) + items = by_yaml_errors(items) + sort_items(items) end private @@ -43,4 +37,78 @@ class PipelinesFinder def tags project.repository.tag_names end + + def by_scope(items) + case params[:scope] + when 'running' + items.running + when 'pending' + items.pending + when 'finished' + items.finished + when 'branches' + from_ids(ids_for_ref(branches)) + when 'tags' + from_ids(ids_for_ref(tags)) + else + items + end + end + + def by_status(items) + return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status]) + + items.where(status: params[:status]) + end + + def by_ref(items) + if params[:ref].present? + items.where(ref: params[:ref]) + else + items + end + end + + def by_name(items) + if params[:name].present? + items.joins(:user).where(users: { name: params[:name] }) + else + items + end + end + + def by_username(items) + if params[:username].present? + items.joins(:user).where(users: { username: params[:username] }) + else + items + end + end + + def by_yaml_errors(items) + case Gitlab::Utils.to_boolean(params[:yaml_errors]) + when true + items.where("yaml_errors IS NOT NULL") + when false + items.where("yaml_errors IS NULL") + else + items + end + end + + def sort_items(items) + order_by = if ALLOWED_INDEXED_COLUMNS.include?(params[:order_by]) + params[:order_by] + else + :id + end + + sort = if params[:sort] =~ /\A(ASC|DESC)\z/i + params[:sort] + else + :desc + end + + items.order(order_by => sort) + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 377b080b3c6..af430270ae4 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -52,7 +52,7 @@ module BlobHelper if !on_top_of_branch?(project, ref) button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' } - elsif blob.valid_lfs_pointer? + elsif blob.stored_externally? 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' @@ -95,7 +95,7 @@ module BlobHelper end def can_modify_blob?(blob, project = @project, ref = @ref) - !blob.valid_lfs_pointer? && can_edit_tree?(project, ref) + !blob.stored_externally? && can_edit_tree?(project, ref) end def leave_edit_message @@ -119,7 +119,9 @@ module BlobHelper end def blob_raw_url - if @snippet + if @build && @entry + raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) else @@ -223,7 +225,9 @@ module BlobHelper end def open_raw_blob_button(blob) - if blob.raw_binary? + return if blob.empty? + + if blob.raw_binary? || blob.stored_externally? icon = icon('download') title = 'Download' else @@ -244,19 +248,29 @@ module BlobHelper viewer.max_size end "it is larger than #{number_to_human_size(max_size)}" - when :server_side_but_stored_in_lfs - "it is stored in LFS" + when :server_side_but_stored_externally + case viewer.blob.external_storage + when :lfs + 'it is stored in LFS' + when :build_artifact + 'it is stored as a job artifact' + else + 'it is stored externally' + end end end def blob_render_error_options(viewer) + error = viewer.render_error options = [] - if viewer.render_error == :too_large && viewer.can_override_max_size? + if error == :too_large && viewer.can_override_max_size? options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil))) end - if viewer.rich? && viewer.blob.rendered_as_text? + # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error, + # so don't bother switching. + if viewer.rich? && viewer.blob.rendered_as_text? && error != :server_side_but_stored_externally options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' }) end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index e9b7cbbad6a..1336c676134 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -208,6 +208,8 @@ module GitlabRoutingHelper browse_namespace_project_build_artifacts_path(*args) when 'file' file_namespace_project_build_artifacts_path(*args) + when 'raw' + raw_namespace_project_build_artifacts_path(*args) end end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index c9e70faa52e..c515774140c 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -115,4 +115,28 @@ module MilestonesHelper end end end + + def milestone_merge_request_tab_path(milestone) + if @project + merge_requests_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end + + def milestone_participants_tab_path(milestone) + if @project + participants_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end + + def milestone_labels_tab_path(milestone) + if @project + labels_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index eab0738a368..08180883eb9 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -60,20 +60,16 @@ module NotesHelper note.project.team.human_max_access(note.author_id) end - def discussion_diff_path(discussion) - if discussion.for_merge_request? && discussion.diff_discussion? - if discussion.active? - # Without a diff ID, the link always points to the latest diff version - diff_id = nil - elsif merge_request_diff = discussion.latest_merge_request_diff - diff_id = merge_request_diff.id - else - # If the discussion is not active, and we cannot find the latest - # merge request diff for this discussion, we return no path at all. - return - end - - diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, diff_id: diff_id, anchor: discussion.line_code) + def discussion_path(discussion) + if discussion.for_merge_request? + return unless discussion.diff_discussion? + + version_params = discussion.merge_request_version_params + return unless version_params + + path_params = version_params.merge(anchor: discussion.line_code) + + diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, path_params) elsif discussion.for_commit? anchor = discussion.line_code if discussion.diff_discussion? diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 2fda98cae90..4882d9b71d2 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -70,6 +70,14 @@ module SortingHelper } end + def tags_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + def sort_title_priority 'Priority' end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index f7b5a5f4dfc..a91e3da309c 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -76,7 +76,7 @@ module TreeHelper "A new branch will be created in your fork and a new merge request will be started." end - def tree_breadcrumbs(tree, max_links = 2) + def path_breadcrumbs(max_links = 6) if @path.present? part_path = "" parts = @path.split('/') @@ -88,7 +88,7 @@ module TreeHelper part_path = part if part_path.empty? next if parts.count > max_links && !parts.last(2).include?(part) - yield(part, tree_join(@ref, part_path)) + yield(part, part_path) end end end diff --git a/app/models/blob.rb b/app/models/blob.rb index 1cdb8811cff..a4fae22a0c4 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -28,7 +28,7 @@ class Blob < SimpleDelegator BlobViewer::Sketch, BlobViewer::Video, - + BlobViewer::PDF, BlobViewer::BinarySTL, @@ -75,19 +75,37 @@ class Blob < SimpleDelegator end def no_highlighting? - size && size > MAXIMUM_TEXT_HIGHLIGHT_SIZE + raw_size && raw_size > MAXIMUM_TEXT_HIGHLIGHT_SIZE + end + + def empty? + raw_size == 0 end def too_large? size && truncated? end + def external_storage_error? + if external_storage == :lfs + !project&.lfs_enabled? + else + false + end + end + + def stored_externally? + return @stored_externally if defined?(@stored_externally) + + @stored_externally = external_storage && !external_storage_error? + end + # Returns the size of the file that this blob represents. If this blob is an # LFS pointer, this is the size of the file stored in LFS. Otherwise, this is # the size of the blob itself. def raw_size - if valid_lfs_pointer? - lfs_size + if stored_externally? + external_size else size end @@ -98,9 +116,13 @@ class Blob < SimpleDelegator # text-based rich blob viewer matched on the file's extension. Otherwise, this # depends on the type of the blob itself. def raw_binary? - if valid_lfs_pointer? + if stored_externally? if rich_viewer rich_viewer.binary? + elsif Linguist::Language.find_by_filename(name).any? + false + elsif _mime_type + _mime_type.binary? else true end @@ -118,15 +140,7 @@ class Blob < SimpleDelegator end def readable_text? - text? && !valid_lfs_pointer? && !too_large? - end - - def valid_lfs_pointer? - lfs_pointer? && project&.lfs_enabled? - end - - def invalid_lfs_pointer? - lfs_pointer? && !project&.lfs_enabled? + text? && !stored_externally? && !too_large? end def simple_viewer @@ -165,10 +179,10 @@ class Blob < SimpleDelegator end def rich_viewer_class - return if invalid_lfs_pointer? || empty? + return if empty? || external_storage_error? classes = - if valid_lfs_pointer? + if stored_externally? BINARY_VIEWERS + TEXT_VIEWERS elsif binary? BINARY_VIEWERS diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index f944b00c9d3..a8b91d8d6bc 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -70,12 +70,13 @@ module BlobViewer return @render_error if defined?(@render_error) @render_error = - if server_side_but_stored_in_lfs? - # Files stored in LFS can only be rendered using a client-side viewer, + if server_side_but_stored_externally? + # Files that are not stored in the repository, like LFS files and + # build artifacts, can only be rendered using a client-side viewer, # since we do not want to read large amounts of data into memory on the # server side. Client-side viewers use JS and can fetch the file from # `blob_raw_url` using AJAX. - :server_side_but_stored_in_lfs + :server_side_but_stored_externally elsif override_max_size ? absolutely_too_large? : too_large? :too_large end @@ -89,8 +90,8 @@ module BlobViewer private - def server_side_but_stored_in_lfs? - server_side? && blob.valid_lfs_pointer? + def server_side_but_stored_externally? + server_side? && blob.stored_externally? end end end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb new file mode 100644 index 00000000000..b35febc9ac5 --- /dev/null +++ b/app/models/ci/artifact_blob.rb @@ -0,0 +1,35 @@ +module Ci + class ArtifactBlob + include BlobLike + + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + delegate :name, :path, to: :entry + + def id + Digest::SHA1.hexdigest(path) + end + + def size + entry.metadata[:size] + end + + def data + "Build artifact #{path}" + end + + def mode + entry.metadata[:mode] + end + + def external_storage + :build_artifact + end + + alias_method :external_size, :size + end +end diff --git a/app/models/commit.rb b/app/models/commit.rb index bb4cb8efd15..88a015cdb77 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -236,8 +236,8 @@ class Commit project.pipelines.where(sha: sha) end - def latest_pipeline - pipelines.last + def last_pipeline + @last_pipeline ||= pipelines.last end def status(ref = nil) diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb new file mode 100644 index 00000000000..adb81561000 --- /dev/null +++ b/app/models/concerns/blob_like.rb @@ -0,0 +1,48 @@ +module BlobLike + extend ActiveSupport::Concern + include Linguist::BlobHelper + + def id + raise NotImplementedError + end + + def name + raise NotImplementedError + end + + def path + raise NotImplementedError + end + + def size + 0 + end + + def data + nil + end + + def mode + nil + end + + def binary? + false + end + + def load_all_data!(repository) + # No-op + end + + def truncated? + false + end + + def external_storage + nil + end + + def external_size + nil + end +end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index 8ee42875670..a7bdf5587b2 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -11,6 +11,7 @@ module DiscussionOnDiff :diff_line, :for_line?, :active?, + :created_at_diff?, to: :first_note diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index 6c27dd5aa5c..6359f7596b1 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -30,6 +30,10 @@ module NoteOnDiff raise NotImplementedError end + def created_at_diff?(diff_refs) + false + end + private def noteable_diff_refs diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 6a6466b493b..d627fbe327f 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -10,7 +10,6 @@ class DiffDiscussion < Discussion delegate :position, :original_position, - :latest_merge_request_diff, to: :first_note @@ -18,6 +17,25 @@ class DiffDiscussion < Discussion false end + def merge_request_version_params + return unless for_merge_request? + + if active? + {} + else + diff_refs = position.diff_refs + + if diff = noteable.merge_request_diff_for(diff_refs) + { diff_id: diff.id } + elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha) + { + diff_id: diff.id, + start_sha: diff_refs.start_sha + } + end + end + end + def reply_attributes super.merge( original_position: original_position.to_json, diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index abe4518d62a..76c59199afd 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -65,10 +65,11 @@ class DiffNote < Note self.position.diff_refs == diff_refs end - def latest_merge_request_diff - return unless for_merge_request? + def created_at_diff?(diff_refs) + return false unless supported? + return true if for_commit? - self.noteable.merge_request_diff_for(self.position.diff_refs) + self.original_position.diff_refs == diff_refs end private diff --git a/app/models/issue.rb b/app/models/issue.rb index 305fc01f041..78bde6820da 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -143,6 +143,14 @@ class Issue < ActiveRecord::Base branches_with_iid - branches_with_merge_request end + # Returns boolean if a related branch exists for the current issue + # ignores merge requests branchs + def has_related_branch? + project.repository.branch_names.any? do |branch| + /\A#{iid}-(?!\d+-stable)/i =~ branch + end + end + # To allow polymorphism with MergeRequest. def source_project project diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb index e617ce36f56..3c1d34db5fa 100644 --- a/app/models/legacy_diff_discussion.rb +++ b/app/models/legacy_diff_discussion.rb @@ -9,14 +9,14 @@ class LegacyDiffDiscussion < Discussion memoized_values << :active - def legacy_diff_discussion? - true - end - def self.note_class LegacyDiffNote end + def legacy_diff_discussion? + true + end + def active?(*args) return @active if @active.present? @@ -27,6 +27,16 @@ class LegacyDiffDiscussion < Discussion !active? end + def merge_request_version_params + return unless for_merge_request? + + if active? + {} + else + nil + end + end + def reply_attributes super.merge(line_code: line_code) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 365fa4f1e70..12c5481cd6d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -374,12 +374,18 @@ class MergeRequest < ActiveRecord::Base merge_request_diff(true) end - def merge_request_diff_for(diff_refs) - @merge_request_diffs_by_diff_refs ||= Hash.new do |h, diff_refs| - h[diff_refs] = merge_request_diffs.viewable.select_without_diff.find_by_diff_refs(diff_refs) - end - - @merge_request_diffs_by_diff_refs[diff_refs] + def merge_request_diff_for(diff_refs_or_sha) + @merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha| + diffs = merge_request_diffs.viewable.select_without_diff + h[diff_refs_or_sha] = + if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs) + diffs.find_by_diff_refs(diff_refs_or_sha) + else + diffs.find_by(head_commit_sha: diff_refs_or_sha) + end + end + + @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha] end def reload_diff_if_branch_changed diff --git a/app/models/note.rb b/app/models/note.rb index e720bfba030..b06985b4a6f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -115,11 +115,19 @@ class Note < ActiveRecord::Base end def grouped_diff_discussions(diff_refs = nil) - diff_notes. - fresh. - discussions. - select { |n| n.active?(diff_refs) }. - group_by(&:line_code) + groups = {} + + diff_notes.fresh.discussions.each do |discussion| + if discussion.active?(diff_refs) + discussions = groups[discussion.line_code] ||= [] + elsif diff_refs && discussion.created_at_diff?(diff_refs) + discussions = groups[discussion.original_line_code] ||= [] + end + + discussions << discussion if discussions + end + + groups end def count_for_collection(ids, type) @@ -141,10 +149,6 @@ class Note < ActiveRecord::Base true end - def latest_merge_request_diff - nil - end - def max_attachment_size current_application_settings.max_attachment_size.megabytes.to_i end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 7621a5fa2d8..e2ad586aea7 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -50,5 +50,16 @@ module ChatMessage def link(text, url) "[#{text}](#{url})" end + + def pretty_duration(seconds) + parse_string = + if duration < 1.hour + '%M:%S' + else + '%H:%M:%S' + end + + Time.at(seconds).utc.strftime(parse_string) + end end end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 4628d9b1a7b..47b68f00cff 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -15,7 +15,7 @@ module ChatMessage @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref = pipeline_attributes[:ref] @status = pipeline_attributes[:status] - @duration = pipeline_attributes[:duration] + @duration = pipeline_attributes[:duration].to_i @pipeline_id = pipeline_attributes[:id] end @@ -37,7 +37,7 @@ module ChatMessage { title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}", subtitle: "in #{project_link}", - text: "in #{duration} #{time_measure}", + text: "in #{pretty_duration(duration)}", image: user_avatar || '' } end @@ -45,7 +45,7 @@ module ChatMessage private def message - "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}" + "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}" end def humanized_status @@ -84,9 +84,5 @@ module ChatMessage def pipeline_link "[##{pipeline_id}](#{pipeline_url})" end - - def time_measure - 'second'.pluralize(duration) - end end end diff --git a/app/models/repository.rb b/app/models/repository.rb index ba34d570dbd..0c797dd5814 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -789,7 +789,7 @@ class Repository } options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) - Rugged::Commit.create(rugged, options) + create_commit(options) end end # rubocop:enable Metrics/ParameterLists @@ -836,7 +836,7 @@ class Repository tree: merge_index.write_tree(rugged), ) - commit_id = Rugged::Commit.create(rugged, actual_options) + commit_id = create_commit(actual_options) merge_request.update(in_progress_merge_commit_sha: commit_id) commit_id end @@ -859,12 +859,11 @@ class Repository committer = user_to_committer(user) - Rugged::Commit.create(rugged, - message: commit.revert_message(user), - author: committer, - committer: committer, - tree: revert_tree_id, - parents: [start_commit.sha]) + create_commit(message: commit.revert_message(user), + author: committer, + committer: committer, + tree: revert_tree_id, + parents: [start_commit.sha]) end end @@ -883,16 +882,15 @@ class Repository committer = user_to_committer(user) - Rugged::Commit.create(rugged, - message: commit.message, - author: { - email: commit.author_email, - name: commit.author_name, - time: commit.authored_date - }, - committer: committer, - tree: cherry_pick_tree_id, - parents: [start_commit.sha]) + create_commit(message: commit.message, + author: { + email: commit.author_email, + name: commit.author_name, + time: commit.authored_date + }, + committer: committer, + tree: cherry_pick_tree_id, + parents: [start_commit.sha]) end end @@ -900,7 +898,7 @@ class Repository GitOperationService.new(user, self).with_branch(branch_name) do committer = user_to_committer(user) - Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer)) + create_commit(params.merge(author: committer, committer: committer)) end end @@ -1142,6 +1140,12 @@ class Repository Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags)) end + def create_commit(params = {}) + params[:message].delete!("\r") + + Rugged::Commit.create(rugged, params) + end + def repository_storage_path @project.repository_storage_path end diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb index d6cab74eb1a..fa5fa151607 100644 --- a/app/models/snippet_blob.rb +++ b/app/models/snippet_blob.rb @@ -1,5 +1,5 @@ class SnippetBlob - include Linguist::BlobHelper + include BlobLike attr_reader :snippet @@ -28,32 +28,4 @@ class SnippetBlob Banzai.render_field(snippet, :content) end - - def mode - nil - end - - def binary? - false - end - - def load_all_data!(repository) - # No-op - end - - def lfs_pointer? - false - end - - def lfs_oid - nil - end - - def lfs_size - nil - end - - def truncated? - false - end end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index d3913986cd8..e1e5336da8c 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -3,11 +3,16 @@ class PersonalSnippetPolicy < BasePolicy can! :read_personal_snippet if @subject.public? return unless @user + if @subject.public? + can! :comment_personal_snippet + end + if @subject.author == @user can! :read_personal_snippet can! :update_personal_snippet can! :destroy_personal_snippet can! :admin_personal_snippet + can! :comment_personal_snippet end unless @user.external? @@ -16,6 +21,7 @@ class PersonalSnippetPolicy < BasePolicy if @subject.internal? && !@user.external? can! :read_personal_snippet + can! :comment_personal_snippet end end end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 86ac513b3c0..070b0c35e36 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -48,6 +48,17 @@ module Projects available_public_keys.any? end + def as_json + serializer = DeployKeySerializer.new + opts = { user: current_user } + + { + enabled_keys: serializer.represent(enabled_keys, opts), + available_project_keys: serializer.represent(available_project_keys, opts), + public_keys: serializer.represent(available_public_keys, opts) + } + end + def to_partial_path 'projects/deploy_keys/index' end diff --git a/app/serializers/README.md b/app/serializers/README.md new file mode 100644 index 00000000000..0337f88db5f --- /dev/null +++ b/app/serializers/README.md @@ -0,0 +1,325 @@ +# Serializers + +This is a documentation for classes located in `app/serializers` directory. + +In GitLab, we use [grape-entities][grape-entity-project], accompanied by a +serializer, to convert a Ruby object to its JSON representation. + +Serializers are typically used in controllers to build a JSON response +that is usually consumed by a frontend code. + +## Why using a serializer is important? + +Using serializers, instead of `to_json` method, has several benefits: + +* it helps to prevent exposure of a sensitive data stored in the database +* it makes it easier to test what should and should not be exposed +* it makes it easier to reuse serialization entities that are building blocks +* it makes it easier to move complexity from controllers to easily testable + classes +* it encourages hiding complexity behind intentions-revealing interfaces +* it makes it easier to take care about serialization performance concerns +* it makes it easier to reduce merge conflicts between CE -> EE +* it makes it easier to benefit from domain driven development techniques + +## What is a serializer? + +A serializer is a class that encapsulates all business rules for building a +JSON response using serialization entities. + +It is designed to be testable and to support passing additional context from +the controller. + +## What is a serialization entity? + +Entities are lightweight structures that allow to represent domain models +in a consistent and abstracted way, and reuse them as building blocks to +create a payload. + +Entities located in `app/serializers` are usually derived from a +[`Grape::Entity`][grape-entity-class] class. + +Serialization entities that do require to have a knowledge about specific +elements of the request, need to mix `RequestAwareEntity` in. + +A serialization entity usually maps a domain model class into its JSON +representation. It rarely happens that a serialization entity exists without +a corresponding domain model class. As an example, we have an `Issue` class and +a corresponding `IssueSerializer`. + +Serialization entites are designed to reuse other serialization entities, which +is a convenient way to create a multi-level JSON representation of a piece of +a domain model you want to serialize. + +See [documentation for Grape Entites][grape-entity-readme] for more details. + +## How to implement a serializer? + +### Base implementation + +In order to effectively implement a serializer it is necessary to create a new +class in `app/serializers`. See existing serializers as an example. + +A new serializer should inherit from a `BaseSerializer` class. It is necessary +to specify which serialization entity will be used to serialize a resource. + +```ruby +class MyResourceSerializer < BaseSerialize + entity MyResourceEntity +end +``` + +The example above shows how a most simple serializer can look like. + +Given that the entity `MyResourceEntity` exists, you can now use +`MyResourceSerializer` in the controller by creating an instance of it, and +calling `MyResourceSerializer#represent(resource)` method. + +Note that a `resource` can be either a single object, an array of objects or an +`ActiveRecord::Relation` object. A serialization entity should be smart enough +to accurately represent each of these. + +It should not be necessary to use `Enumerable#map`, and it should be avoided +from the performance reasons. + +### Choosing what gets serialized + +It often happens that you might want to use the same serializer in many places, +but sometimes the intention is to only expose a small subset of object's +attributes in one place, and a different subset in another. + +`BaseSerializer#represent(resource, opts = {})` method can take an additional +hash argument, `opts`, that defines what is going to be serialized. + +`BaseSerializer` will pass these options to a serialization entity. See +how it is [documented in the upstream project][grape-entity-only]. + +With this approach you can extend the serializer to respond to methods that will +create a JSON response according to your needs. + +```ruby +class PipelineSerializer < BaseSerializer + entity PipelineEntity + + def represent_details(resource) + represent(resource, only: [:details]) + end + + def represent_status(resource) + represent(resource, only: [:status]) + end +end +``` + +It is possible to use `only` and `except` keywords. Both keywords do support +nested attributes, like `except: [:id, { user: [:id] }]`. + +Passing `only` and `except` to the `represent` method from a controller is +possible, but it defies principles of encapsulation and testability, and it is +better to avoid it, and to add a specific method to the serializer instead. + +### Reusing serialization entities from the API + +Public API in GitLab is implemented using [Grape][grape-project]. + +Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes. +This means that it is possible to reuse these classes to implement internal +serializers. + +You can either use such entity directly: + +```ruby +class MyResourceSerializer < BaseSerializer + entity API::Entities::SomeEntity +end +``` + +Or derive a new serialization entity class from it: + +```ruby +class MyEntity < API::Entities::SomeEntity + include RequestAwareEntity + + unexpose :something +end +``` + +It might be a good idea to write specs for entities that do inherit from +the API, because when API payloads are changed / extended, it is easy to forget +about the impact on the internal API through a serializer that reuses API +entities. + +It is usually safe to do that, because API entities rarely break backward +compatibility, but additional exposure may have a performance impact when API +gets extended significantly. Write tests that check if only necessary data is +exposed. + +## How to write tests for a serializer? + +Like every other class in the project, creating a serializer warrants writing +tests for it. + +It is usually a good idea to test each public method in the serializer against +a valid payload. `BaseSerializer#represent` returns a hash, so it is possible +to use usual RSpec matchers like `include`. + +Sometimes, when the payload is large, it makes sense to validate it entirely +using `match_response_schema` matcher along with a new fixture that can be +stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema` +gem, which is quite flexible, see a [documentation][json-schema-gem] for it. + +## How to use a serializer in a controller? + +Once a new serializer is implemented, it is possible to use it in a controller. + +Create an instance of the serializer and render the response. + +```ruby +def index + format.json do + render json: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources) + nd +end +``` + +If it is necessary to include additional information in the payload, it is +possible to extend what is going to be rendered, the usual way: + +```ruby +def index + format.json do + render json: { + resources: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources), + count: @project.resources.count + } + nd +end +``` + +Note that in these examples an additional context is being passed to the +serializer (`current_user: @current_user`). + +## How to pass an additional context from the controller? + +It is possible to pass an additional context from a controller to a +serializer and each serialization entity that is used in the process. + +Serialization entities that do require an additional context have +`RequestAwareEntity` concern mixed in. This piece of the code exposes a method +called `request` in every serialization entity that is instantiated during +serialization. + +An object returned by this method is an instance of `EntityRequest`, which +behaves like an `OpenStruct` object, with the difference that it will raise +an error if an unknown method is called. + +In other words, in the previous example, `request` method will return an +instance of `EntityRequest` that responds to `current_user` method. It will be +available in every serialization entity instantiated by `MyResourceSerializer`. + +`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be +refactored soon. Please avoid passing an additional context that is not +required by a serialization entity. + +At the moment, the context that is passed to entities most often is +`current_user` and `project`. + +## How is this related to using presenters? + +Payload created by a serializer is usually a representation of the backed code, +combined with the current request data. Therefore, technically, serializers +are presenters that create payload consumed by a frontend code, usually Vue +components. + +In GitLab, it is possible to use [presenters][presenters-readme], but +`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898]. + +It is possible to use presenters when serializer is used to represent only +a single object. It is not supported when `ActiveRecord::Relation` is being +serialized. + +```ruby +MyObjectSerializer.new.represent(object.present) +``` + +## Best practices + +1. Do not invoke a serializer from within a serialization entity. + + If you need to use a serializer from within a serialization entity, it is + possible that you are missing a class for an important domain concept. + + Consider creating a new domain class and a corresponding serialization + entity for it. + +1. Use only one approach to switch behavior of the serializer. + + It is possible to use a few approaches to switch a behavior of the + serializer. Most common are using a [Fluent Interface][fluent-interface] + and creating a separate `represent_something` methods. + + Whatever you choose, it might be better to use only one approach at a time. + +1. Do not forget about creating specs for serialization entities. + + Writing tests for the serializer indeed does cover testing a behavior of + serialization entities that the serializer instantiates. However it might + be a good idea to write separate tests for entities as well, because these + are meant to be reused in different serializers, and a serializer can + change a behavior of a serialization entity. + +1. Use `ActiveRecord::Relation` where possible + + Using an `ActiveRecord::Relation` might help from the performance perspective. + +1. Be diligent about passing an additional context from the controller. + + Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack + of high-level mechanism. It is meant to be refactored, and current + implementation is error prone. Imagine the situation that one serialization + entity requires `request.user` attribute, but the second one wants + `request.current_user`. When it happens that these two entities are used in + the same serialization request, you might need to pass both parameters to + the serializer, which is obviously not a perfect situation. + + When in doubt, pass only `current_user` and `project` if these are required. + +1. Keep performance concerns in mind + + Using a serializer incorrectly can have significant impact on the + performance. + + Because serializers are technically presenters, it is often necessary + to calculate, for example, paths to various controller-actions. + Since using URL helpers usually involve passing `project` and `namespace` + adding `includes(project: :namespace)` in the serializer, can help to avoid + N+1 queries. + + Also, try to avoid using `Enumerable#map` or other methods that will + execute a database query eagerly. + +1. Avoid passing `only` and `except` from the controller. +1. Write tests checking for N+1 queries. +1. Write controller tests for actions / formats using serializers. +1. Write tests that check if only necessary data is exposed. +1. Write tests that check if no sensitive data is exposed. + +## Future + +* [Next iteration of serializers][issue-27569] + +[grape-project]: http://www.ruby-grape.org +[grape-entity-project]: https://github.com/ruby-grape/grape-entity +[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md +[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb +[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want +[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md +[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface +[json-schema-gem]: https://github.com/ruby-json-schema/json-schema +[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045 +[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898 +[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569 diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb new file mode 100644 index 00000000000..d75a83d0fa5 --- /dev/null +++ b/app/serializers/deploy_key_entity.rb @@ -0,0 +1,14 @@ +class DeployKeyEntity < Grape::Entity + expose :id + expose :user_id + expose :title + expose :fingerprint + expose :can_push + expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned + expose :almost_orphaned?, as: :almost_orphaned + expose :created_at + expose :updated_at + expose :projects, using: ProjectEntity do |deploy_key| + deploy_key.projects.select { |project| options[:user].can?(:read_project, project) } + end +end diff --git a/app/serializers/deploy_key_serializer.rb b/app/serializers/deploy_key_serializer.rb new file mode 100644 index 00000000000..8f849eb88b7 --- /dev/null +++ b/app/serializers/deploy_key_serializer.rb @@ -0,0 +1,3 @@ +class DeployKeySerializer < BaseSerializer + entity DeployKeyEntity +end diff --git a/app/serializers/merge_request_create_entity.rb b/app/serializers/merge_request_create_entity.rb new file mode 100644 index 00000000000..11234313293 --- /dev/null +++ b/app/serializers/merge_request_create_entity.rb @@ -0,0 +1,7 @@ +class MergeRequestCreateEntity < Grape::Entity + expose :iid + + expose :url do |merge_request| + Gitlab::UrlBuilder.build(merge_request) + end +end diff --git a/app/serializers/merge_request_create_serializer.rb b/app/serializers/merge_request_create_serializer.rb new file mode 100644 index 00000000000..08daf473319 --- /dev/null +++ b/app/serializers/merge_request_create_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestCreateSerializer < BaseSerializer + entity MergeRequestCreateEntity +end diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb new file mode 100644 index 00000000000..a471a7e6a88 --- /dev/null +++ b/app/serializers/project_entity.rb @@ -0,0 +1,14 @@ +class ProjectEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :name + + expose :full_path do |project| + namespace_project_path(project.namespace, project) + end + + expose :full_name do |project| + project.full_name + end +end diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb new file mode 100644 index 00000000000..738cedbaed7 --- /dev/null +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -0,0 +1,54 @@ +module MergeRequests + class CreateFromIssueService < MergeRequests::CreateService + def execute + return error('Invalid issue iid') unless issue_iid.present? && issue.present? + + result = CreateBranchService.new(project, current_user).execute(branch_name, ref) + return result if result[:status] == :error + + SystemNoteService.new_issue_branch(issue, project, current_user, branch_name) + + new_merge_request = create(merge_request) + + if new_merge_request.valid? + success(new_merge_request) + else + error(new_merge_request.errors) + end + end + + private + + def issue_iid + @isssue_iid ||= params.delete(:issue_iid) + end + + def issue + @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid) + end + + def branch_name + @branch_name ||= issue.to_branch_name + end + + def ref + project.default_branch || 'master' + end + + def merge_request + MergeRequests::BuildService.new(project, current_user, merge_request_params).execute + end + + def merge_request_params + { + source_project_id: project.id, + source_branch: branch_name, + target_project_id: project.id + } + end + + def success(merge_request) + super().merge(merge_request: merge_request) + end + end +end diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb index 3cf4264ce9b..121385afca3 100644 --- a/app/services/projects/enable_deploy_key_service.rb +++ b/app/services/projects/enable_deploy_key_service.rb @@ -4,7 +4,10 @@ module Projects key = accessible_keys.find_by(id: params[:key_id] || params[:id]) return unless key - project.deploy_keys << key + unless project.deploy_keys.include?(key) + project.deploy_keys << key + end + key end diff --git a/app/services/projects/upload_service.rb b/app/services/projects/upload_service.rb deleted file mode 100644 index be34d4fa9b8..00000000000 --- a/app/services/projects/upload_service.rb +++ /dev/null @@ -1,22 +0,0 @@ -module Projects - class UploadService < BaseService - def initialize(project, file) - @project, @file = project, file - end - - def execute - return nil unless @file && @file.size <= max_attachment_size - - uploader = FileUploader.new(@project) - uploader.store!(@file) - - uploader.to_h - end - - private - - def max_attachment_size - current_application_settings.max_attachment_size.megabytes.to_i - end - end -end diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb new file mode 100644 index 00000000000..6c5b2baff41 --- /dev/null +++ b/app/services/upload_service.rb @@ -0,0 +1,20 @@ +class UploadService + def initialize(model, file, uploader_class = FileUploader) + @model, @file, @uploader_class = model, file, uploader_class + end + + def execute + return nil unless @file && @file.size <= max_attachment_size + + uploader = @uploader_class.new(@model) + uploader.store!(@file) + + uploader.to_h + end + + private + + def max_attachment_size + current_application_settings.max_attachment_size.megabytes.to_i + end +end diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb index e84944ed411..3e36ec91205 100644 --- a/app/uploaders/artifact_uploader.rb +++ b/app/uploaders/artifact_uploader.rb @@ -30,8 +30,4 @@ class ArtifactUploader < GitlabUploader def filename file.try(:filename) end - - def exists? - file.try(:exists?) - end end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index d2783ce5b2f..7e94218c23d 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -26,11 +26,11 @@ class FileUploader < GitlabUploader File.join(CarrierWave.root, base_dir, model.path_with_namespace) end - attr_accessor :project + attr_accessor :model attr_reader :secret - def initialize(project, secret = nil) - @project = project + def initialize(model, secret = nil) + @model = model @secret = secret || generate_secret end @@ -38,10 +38,6 @@ class FileUploader < GitlabUploader File.join(dynamic_path_segment, @secret) end - def model - project - end - def relative_path self.file.path.sub("#{dynamic_path_segment}/", '') end diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index d662ba6820c..e0a6c9b4067 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -33,4 +33,8 @@ class GitlabUploader < CarrierWave::Uploader::Base def relative_path self.file.path.sub("#{root}/", '') end + + def exists? + file.try(:exists?) + end end diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index faab539b8e0..95a891111e1 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -9,10 +9,6 @@ class LfsObjectUploader < GitlabUploader "#{Gitlab.config.lfs.storage_path}/tmp/cache" end - def exists? - file.try(:exists?) - end - def filename model.oid[4..-1] end diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb new file mode 100644 index 00000000000..969b0a20d38 --- /dev/null +++ b/app/uploaders/personal_file_uploader.rb @@ -0,0 +1,15 @@ +class PersonalFileUploader < FileUploader + def self.dynamic_path_segment(model) + File.join(CarrierWave.root, model_path(model)) + end + + private + + def secure_url + File.join(self.class.model_path(model), secret, file.filename) + end + + def self.model_path(model) + File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s) + end +end diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb index 226eb6b313c..d992b0c3725 100644 --- a/app/validators/dynamic_path_validator.rb +++ b/app/validators/dynamic_path_validator.rb @@ -115,13 +115,20 @@ class DynamicPathValidator < ActiveModel::EachValidator # this would map to the activity-page of it's parent. GROUP_ROUTES = %w[ activity + analytics + audit_events avatar edit group_members + hooks issues labels + ldap + ldap_group_links merge_requests milestones + notification_setting + pipeline_quota projects subgroups ].freeze diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 840d843f069..89d0bbb7126 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -175,11 +175,7 @@ .panel-body - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting a user has the following effects: - %ul - %li All user content like authored issues, snippets, comments will be removed - - rp = @user.personal_projects.count - - unless rp.zero? - %li #{pluralize rp, 'personal project'} will be removed and cannot be restored + = render 'users/deletion_guidance', user: @user %br = link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" - else diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 549364761e6..78c5b0c1dda 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -3,7 +3,7 @@ .diff-file.file-holder .js-file-title.file-title - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_diff_path(discussion) + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion) .diff-content.code.js-syntax-highlight %table diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 8440fb3d785..38e85168f40 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -20,7 +20,7 @@ = discussion.author.to_reference started a discussion - - url = discussion_diff_path(discussion) + - url = discussion_path(discussion) - if discussion.for_commit? && @noteable != discussion.noteable on - commit = discussion.noteable diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 8ab9747efc5..cdcac7e4264 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -38,7 +38,7 @@ %span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :environments]) do + = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index f5e7ea7710d..e9e06e5c8e3 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -11,7 +11,7 @@ - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project) - if current_user :javascript - window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; + window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; window.preview_markdown_path = "#{preview_markdown_path}"; - content_for :header_content do diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index d843cacd52d..73f33e69d68 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -118,11 +118,7 @@ - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting an account has the following effects: - %ul - %li All user content like authored issues, snippets, comments will be removed - - rp = current_user.personal_projects.count - - unless rp.zero? - %li #{pluralize rp, 'personal project'} will be removed and cannot be restored + = render 'users/deletion_guidance', user: current_user = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove" - else - if @user.solo_owned_groups.present? diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml index 9e49c93388a..34d5c3b7285 100644 --- a/app/views/projects/artifacts/_tree_directory.html.haml +++ b/app/views/projects/artifacts/_tree_directory.html.haml @@ -3,6 +3,6 @@ %tr.tree-item{ 'data-link' => path_to_directory } %td.tree-item-file-name = tree_icon('folder', '755', directory.name) - %span.str-truncated - = link_to directory.name, path_to_directory + = link_to path_to_directory do + %span.str-truncated= directory.name %td diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index 36fb4c998c9..ce7e25d774b 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,9 +1,10 @@ - path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } + - blob = file.blob %td.tree-item-file-name - = tree_icon('file', '664', file.name) - %span.str-truncated - = link_to file.name, path_to_file + = tree_icon('file', blob.mode, blob.name) + = link_to path_to_file do + %span.str-truncated= blob.name %td - = number_to_human_size(file.metadata[:size], precision: 2) + = number_to_human_size(blob.size, precision: 2) diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index de8c173f26f..9fbb30f7c7c 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,13 +1,23 @@ -- page_title 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' +- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" -.top-block.row-content-block.clearfix - .pull-right - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), - rel: 'nofollow', download: '', class: 'btn btn-default download' do - = icon('download') - Download artifacts archive += render "projects/builds/header", show_controls: false .tree-holder + .nav-block + .tree-controls + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), + rel: 'nofollow', download: '', class: 'btn btn-default download' do + = icon('download') + Download artifacts archive + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + .tree-content-holder %table.table.tree-table %thead diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml new file mode 100644 index 00000000000..d8da83b9a80 --- /dev/null +++ b/app/views/projects/artifacts/file.html.haml @@ -0,0 +1,33 @@ +- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" + += render "projects/builds/header", show_controls: false + +#tree-holder.tree-holder + .nav-block + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + %strong= title + - else + = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + + + %article.file-holder + - blob = @entry.blob + .js-file-title.file-title-flex-parent + = render 'projects/blob/header_content', blob: blob + + .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob + + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) + = open_raw_blob_button(blob) + + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 3f12d64d044..f04df441ccb 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -6,17 +6,14 @@ %li = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do = @project.path - - tree_breadcrumbs(@tree, 6) do |title, path| + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) %li - - if path - - if path.end_with?(@path) - = link_to namespace_project_blob_path(@project.namespace, @project, path) do - %strong - = truncate(title, length: 40) - - else - = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path) + - if path == @path + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do + %strong= title - else - = link_to title, '#' + = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) %ul.blob-commit-info.hidden-xs - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) @@ -25,5 +22,4 @@ #blob-content-holder.blob-content-holder %article.file-holder = render "projects/blob/header", blob: blob - = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 219dc14645b..cd098acda81 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -1,15 +1,6 @@ - blame = local_assigns.fetch(:blame, false) .js-file-title.file-title-flex-parent - .file-header-content - = blob_icon blob.mode, blob.name - - %strong.file-title-name - = blob.name - - = copy_file_path_button(blob.path) - - %small - = number_to_human_size(blob.raw_size) + = render 'projects/blob/header_content', blob: blob .file-actions.hidden-xs = render 'projects/blob/viewer_switcher', blob: blob unless blame diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml new file mode 100644 index 00000000000..98bedae650a --- /dev/null +++ b/app/views/projects/blob/_header_content.html.haml @@ -0,0 +1,10 @@ +.file-header-content + = blob_icon blob.mode, blob.name + + %strong.file-title-name + = blob.name + + = copy_file_path_button(blob.path) + + %small + = number_to_human_size(blob.raw_size) diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index 104db85809c..a0f8f105d9a 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -1,10 +1,13 @@ +- show_controls = local_assigns.fetch(:show_controls, true) - pipeline = @build.pipeline .content-block.build-header.top-area .header-content = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title - Job - %strong.js-build-id ##{@build.id} + %strong + Job + = link_to namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' do + \##{@build.id} in pipeline = link_to pipeline_path(pipeline) do %strong ##{pipeline.id} @@ -15,13 +18,16 @@ = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do %code = @build.ref - - if @build.user - = render "user" + + = render "projects/builds/user" if @build.user + = time_ago_with_tooltip(@build.created_at) - .nav-controls - - if can?(current_user, :create_issue, @project) && @build.failed? - = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post - %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } - = icon('angle-double-left') + + - if show_controls + .nav-controls + - if can?(current_user, :create_issue, @project) && @build.failed? + = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' + - if can?(current_user, :update_build, @build) && @build.retryable? + = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post + %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } + = icon('angle-double-left') diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index c4159ce1a36..43191fae9e6 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -48,7 +48,7 @@ - if @build.merge_request %p.build-detail-row %span.build-light-text Merge Request: - = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) + = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'bold' - if @build.duration %p.build-detail-row %span.build-light-text Duration: diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index f604d6e5fbb..64adb70cb81 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -61,19 +61,20 @@ %span.commit-info.branches %i.fa.fa-spinner.fa-spin - - if @commit.status + - if @commit.last_pipeline + - last_pipeline = @commit.last_pipeline .well-segment.pipeline-info .status-icon-container{ class: "ci-status-icon-#{@commit.status}" } - = link_to namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id) do - = ci_icon_for_status(@commit.status) + = link_to namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) do + = ci_icon_for_status(last_pipeline.status) Pipeline - = link_to "##{@commit.latest_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id), class: "monospace" - = ci_label_for_status(@commit.status) - - if @commit.latest_pipeline.stages.any? + = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id), class: "monospace" + = ci_label_for_status(last_pipeline.status) + - if last_pipeline.stages.any? .mr-widget-pipeline-graph - = render 'shared/mini_pipeline_graph', pipeline: @commit.latest_pipeline, klass: 'js-commit-pipeline-graph' + = render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph' in - = time_interval_in_words @commit.pipelines.total_duration + = time_interval_in_words last_pipeline.duration :javascript $(".commit-info.branches").load("#{branches_namespace_project_commit_path(@project.namespace, @project, @commit.id)}"); diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 0d11da2451a..16d2646cb4e 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -1,9 +1,11 @@ - @no_container = true +- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : '' +- limited_container_width = fluid_layout || diff_view == :inline ? '' : 'limit-container-width' - page_title "#{@commit.title} (#{@commit.short_id})", "Commits" - page_description @commit.description = render "projects/commits/head" -%div{ class: container_class } +.container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" - if @commit.status = render "ci_menu" diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 4cfbd9add00..74756b58439 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -10,25 +10,4 @@ = render @deploy_keys.form_partial_path .col-lg-9.col-lg-offset-3 %hr - .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys - %h5.prepend-top-0 - Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size}) - - if @deploy_keys.any_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys found. Create one with the form above. - %h5.prepend-top-default - Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size}) - - if @deploy_keys.any_available_project_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys from your projects could be found. Create one with the form above or add existing one below. - - if @deploy_keys.any_available_public_keys_enabled? - %h5.prepend-top-default - Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size}) - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key + #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } } diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 3e426ee9e7d..7439b8a66f7 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -35,6 +35,6 @@ - else = diff_line_content(line.text) -- if line_discussions +- if line_discussions&.any? - discussion_expanded = local_assigns.fetch(:discussion_expanded, line_discussions.any?(&:expanded?)) = render "discussions/diff_discussion", discussions: line_discussions, expanded: discussion_expanded diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 13e2150f997..6bc6bf76e18 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,9 +1,29 @@ - if can?(current_user, :push_code, @project) - .pull-right - #new-branch.new-branch{ 'data-path' => can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue) } - = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid), - method: :post, class: 'btn btn-new btn-inverted btn-grouped has-tooltip available hide', title: @issue.to_branch_name do - New branch - = link_to '#', class: 'unavailable btn btn-grouped hide', disabled: 'disabled' do - = icon('exclamation-triangle') - New branch unavailable + .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue), create_mr_path: create_merge_request_namespace_project_issue_path(@project.namespace, @project, @issue), create_branch_path: namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } } + .btn-group.unavailable + %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } + = icon('spinner', class: 'fa-spin') + %span.text + Checking branch availability… + .btn-group.available.hide + %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: 'Create a merge request', data: { action: 'create-mr' } } + %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } } + = icon('caret-down') + %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } } + %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a merge request + %span + Creates a branch named after this issue and a merge request. The source branch is '#{@project.default_branch}' by default. + %li.divider.droplab-item-ignore + %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a branch + %span + Creates a branch named after this issue. The source branch is '#{@project.default_branch}' by default. diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 2a871966aa8..1418ad73553 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -70,8 +70,11 @@ // This element is filled in using JavaScript. .content-block.content-block-small - = render 'new_branch' unless @issue.confidential? - = render 'award_emoji/awards_block', awardable: @issue, inline: true + .row + .col-sm-6 + = render 'award_emoji/awards_block', awardable: @issue, inline: true + .col-sm-6.new-branch-col + = render 'new_branch' unless @issue.confidential? %section.issuable-discussion = render 'projects/issues/discussion' diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml index 547be78992e..11b0c55be0b 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -35,7 +35,7 @@ %span.dropdown.inline.mr-version-compare-dropdown %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} } %span - - if @start_sha + - if @start_version version #{version_index(@start_version)} - else #{@merge_request.target_branch} @@ -59,7 +59,7 @@ %small = time_ago_with_tooltip(merge_request_diff.created_at) %li - = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_sha) do + = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do %strong #{@merge_request.target_branch} (base) .monospace= short_sha(@merge_request_diff.base_commit_sha) @@ -75,13 +75,15 @@ = succeed '.' do %code= @merge_request.target_branch - - if @diff_notes_disabled + - if @start_version || !@merge_request_diff.latest? .comments-disabled-notif.content-block = icon('info-circle') - - if @start_sha - Comments are disabled because you're comparing two versions of this merge request. + Not all comments are displayed because you're + - if @start_version + comparing two versions - else - Discussions on this version of the merge request are displayed but comment creation is disabled. + viewing an old version + of this merge request. .pull-right = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 718b52dd82e..d70ec8a6062 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -31,14 +31,14 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml index 8b4e5928e0d..a1efc0b051a 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/projects/notes/_edit_form.html.haml @@ -7,7 +7,7 @@ = render 'projects/notes/hints' .note-form-actions.clearfix - .settings-message.note-edit-warning.js-edit-warning + .settings-message.note-edit-warning.js-finish-edit-warning Finish editing this message first! = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-button' %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' } diff --git a/app/views/projects/pages/_disabled.html.haml b/app/views/projects/pages/_disabled.html.haml deleted file mode 100644 index ad51fbc6cab..00000000000 --- a/app/views/projects/pages/_disabled.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.panel.panel-default - .nothing-here-block - GitLab Pages are disabled. - Ask your system's administrator to enable it. diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 259d5bd63d6..b22a54d75c8 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -16,13 +16,10 @@ %hr.clearfix -- if Gitlab.config.pages.enabled - = render 'access' - = render 'use' - - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https - = render 'list' - - else - = render 'no_domains' - = render 'destroy' += render 'access' += render 'use' +- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render 'list' - else - = render 'disabled' + = render 'no_domains' += render 'destroy' diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index bc57f7f1c46..b0dac9de1c6 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -4,13 +4,13 @@ .nav-links.sub-nav.scrolling-tabs %ul{ class: (container_class) } - if project_nav_tab? :pipelines - = nav_link(path: 'pipelines#index', controller: :pipelines) do + = nav_link(path: ['pipelines#index', 'pipelines#show']) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines - if project_nav_tab? :builds - = nav_link(controller: :builds) do + = nav_link(controller: [:builds, :artifacts]) do = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do %span Jobs diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml index ab0771b5751..f83521052ed 100644 --- a/app/views/projects/project_members/_index.html.haml +++ b/app/views/projects/project_members/_index.html.haml @@ -6,6 +6,12 @@ %p Add a new member to %strong= @project.name + - else + %p + Members can be added by project + %i Masters + or + %i Owners .col-lg-9 .light.prepend-top-default - if can?(current_user, :admin_project_member, @project) diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 81d57c77edf..7b1a26043e1 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -1,9 +1,11 @@ .panel.panel-default - .panel-heading - Members with access to - %strong= @project.name + .panel-heading.flex-project-members-panel + %span.flex-project-title + Members of + %strong + #{@project.name} %span.badge= @project_members.total_count - = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do + = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index 6e187b54a59..af9a080f0a2 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -9,7 +9,7 @@ .form-group = f.label :name, class: 'col-md-2 text-right' do Tag: - .col-md-10 + .col-md-10.protected-tags-dropdown = render partial: "projects/protected_tags/dropdown", locals: { f: f } .help-block = link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags') diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml index 5a5ade03624..8c7f9e0191e 100644 --- a/app/views/projects/settings/_head.html.haml +++ b/app/views/projects/settings/_head.html.haml @@ -27,7 +27,8 @@ = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do %span CI/CD Pipelines - = nav_link(controller: :pages) do - = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do - %span - Pages + - if Gitlab.config.pages.enabled + = nav_link(controller: :pages) do + = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do + %span + Pages diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 5402320cb66..4e59033c4a3 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,6 +1,10 @@ - page_title "Repository" = render "projects/settings/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('deploy_keys') + = render @deploy_keys = render "projects/protected_branches/index" = render "projects/protected_tags/index" diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 7f9a44e565f..56656ea3d86 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -1,4 +1,5 @@ - @no_container = true +- @sort ||= sort_value_recently_updated - page_title "Tags" = render "projects/commits/head" @@ -14,16 +15,14 @@ .dropdown %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light - = projects_sort_options_hash[@sort] + = tags_sort_options_hash[@sort] = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to filter_tags_path(sort: sort_value_name) do - = sort_title_name - = link_to filter_tags_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to filter_tags_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - tags_sort_options_hash.each do |value, title| + %li + = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value) - if can?(current_user, :push_code, @project) = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do New tag diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index e7b3fe3ffda..396d1ecd77b 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -9,12 +9,9 @@ %li = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do = @project.path - - tree_breadcrumbs(tree, 6) do |title, path| + - path_breadcrumbs do |title, path| %li - - if path - = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path) - - else - = link_to title, '#' + = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) - if current_user %li diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index b0778653d4e..07970ad9cba 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -11,8 +11,8 @@ = icon('caret-down') %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container - .arrow-up - .js-builds-dropdown-list.scrollable-menu + %li.js-builds-dropdown-list.scrollable-menu - .js-builds-dropdown-loading.builds-dropdown-loading.hidden - %span.fa.fa-spinner.fa-spin + %li.js-builds-dropdown-loading.hidden + .text-center + %i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 45065ac5920..bc638e994f3 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -136,7 +136,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(:path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @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: (namespace_project_labels_path(@project.namespace, @project, :json) 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/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 2793e7bcff4..f57b4d899ce 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -10,12 +10,16 @@ = form.label :source_branch, class: 'control-label' .col-sm-10 .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 span2', disabled: true }) + = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2', disabled: true }) .form-group = form.label :target_branch, class: 'control-label' - .col-sm-10 + .col-sm-10.target-branch-select-dropdown-container .issuable-form-select-holder - = form.select(:target_branch, issuable.target_branches, { include_blank: true }, { class: 'target_branch select2 span2', disabled: issuable.new_record?, data: { placeholder: "Select branch" }}) + = form.select(:target_branch, issuable.target_branches, + { include_blank: true }, + { class: 'target_branch js-target-branch-select', + disabled: issuable.new_record?, + data: { placeholder: "Select branch" }}) - if issuable.new_record? = link_to 'Change branches', mr_change_branches_path(issuable) diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml new file mode 100644 index 00000000000..68458c2d0aa --- /dev/null +++ b/app/views/shared/milestones/_tab_loading.html.haml @@ -0,0 +1,2 @@ +.text-center.prepend-top-default + = icon('spin spinner 2x', 'aria-hidden': 'true', 'aria-label': 'Loading tab content') diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 9a4502873ef..6a6d817b344 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,27 +1,27 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= icon('angle-left') .fade-right= icon('angle-right') - %ul.nav-links.scrolling-tabs + %ul.nav-links.scrolling-tabs.js-milestone-tabs - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) %li.active = link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do Issues %span.badge= milestone.issues_visible_to_user(current_user).size %li - = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do + = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do Merge Requests %span.badge= milestone.merge_requests.size - else %li.active - = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do + = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do Merge Requests %span.badge= milestone.merge_requests.size %li - = link_to '#tab-participants', 'data-toggle' => 'tab' do + = link_to '#tab-participants', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do Participants %span.badge= milestone.participants.count %li - = link_to '#tab-labels', 'data-toggle' => 'tab' do + = link_to '#tab-labels', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do Labels %span.badge= milestone.labels.count @@ -30,14 +30,18 @@ .tab-content.milestone-content - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) - .tab-pane.active#tab-issues + .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } = render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user).include_associations, show_project_name: show_project_name, show_full_project_name: show_full_project_name - .tab-pane#tab-merge-requests - = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane#tab-merge-requests{ data: { sort_endpoint: (sort_merge_requests_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } + -# loaded async + = render "shared/milestones/tab_loading" - else - .tab-pane.active#tab-merge-requests - = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane.active#tab-merge-requests{ data: { sort_endpoint: (sort_merge_requests_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-participants - = render 'shared/milestones/participants_tab', users: milestone.participants + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-labels - = render 'shared/milestones/labels_tab', labels: milestone.labels + -# loaded async + = render "shared/milestones/tab_loading" diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 731270d4127..9657b4eea82 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -2,7 +2,11 @@ - return if note.cross_reference_not_visible_for?(current_user) - note_editable = note_editable?(note) -%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} } +%li.timeline-entry{ id: dom_id(note), + class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], + data: { author_id: note.author.id, + editable: note_editable, + note_id: note.id } } .timeline-entry-inner .timeline-icon - if note.system diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 9bcb4544b97..11f0fa7c49f 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -1,15 +1,6 @@ - blob = @snippet.blob .js-file-title.file-title-flex-parent - .file-header-content - = blob_icon blob.mode, blob.path - - %strong.file-title-name - = blob.path - - = copy_file_path_button(blob.path) - - %small - = number_to_human_size(blob.raw_size) + = render 'projects/blob/header_content', blob: blob .file-actions.hidden-xs = render 'projects/blob/viewer_switcher', blob: blob diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index dace11e5474..679a5e934da 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -1,13 +1,13 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml new file mode 100644 index 00000000000..0545cab538c --- /dev/null +++ b/app/views/users/_deletion_guidance.html.haml @@ -0,0 +1,10 @@ +- user = local_assigns.fetch(:user) + +%ul + %li + %p + Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the + = link_to 'user account deletion documentation.', help_page_path("user/profile/account/delete_account", anchor: "associated-records") + - personal_projects_count = user.personal_projects.count + - unless personal_projects_count.zero? + %li #{pluralize(personal_projects_count, 'personal project')} will be removed and cannot be restored |