diff options
Diffstat (limited to 'app/assets')
135 files changed, 2255 insertions, 2371 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index d1396b6c4bc..85eb08cc97d 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,6 +5,7 @@ import axios from './lib/utils/axios_utils'; const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', + groupMembersPath: '/api/:version/groups/:id/members', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', @@ -40,6 +41,12 @@ const Api = { }); }, + groupMembers(id) { + const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url); + }, + // Return groups list. Filtered by query groups(query, options, callback = $.noop) { const url = Api.buildUrl(Api.groupsPath); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index cace8bb9dba..73ce3e760ab 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -437,7 +437,7 @@ export class AwardsHandler { createAwardButtonForVotesBlock(votesBlock, emojiName) { const buttonHtml = ` - <button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom"> + <button class="btn award-control js-emoji-btn has-tooltip active" title="You"> ${this.emoji.glEmojiTag(emojiName)} <span class="award-control-text js-counter">1</span> </button> diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 56293d5f96f..d1d75658181 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,11 +1,10 @@ -import installCustomElements from 'document-register-element'; +import 'document-register-element'; import isEmojiUnicodeSupported from '../emoji/support'; -installCustomElements(window); +class GlEmoji extends HTMLElement { + constructor() { + super(); -export default function installGlEmojiElement() { - const GlEmojiElementProto = Object.create(HTMLElement.prototype); - GlEmojiElementProto.createdCallback = function createdCallback() { const emojiUnicode = this.textContent.trim(); const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset; @@ -43,9 +42,11 @@ export default function installGlEmojiElement() { }); } } - }; + } +} - document.registerElement('gl-emoji', { - prototype: GlEmojiElementProto, - }); +export default function installGlEmojiElement() { + if (!customElements.get('gl-emoji')) { + customElements.define('gl-emoji', GlEmoji); + } } diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 947d019c725..52d9f2f0322 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,8 +1,5 @@ import $ from 'jquery'; -import { DOMParser } from 'prosemirror-model'; import { getSelectedFragment } from '~/lib/utils/common_utils'; -import schema from './schema'; -import markdownSerializer from './serializer'; export class CopyAsGFM { constructor() { @@ -39,9 +36,13 @@ export class CopyAsGFM { div.appendChild(el.cloneNode(true)); const html = div.innerHTML; - clipboardData.setData('text/plain', el.textContent); - clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); - clipboardData.setData('text/html', html); + CopyAsGFM.nodeToGFM(el) + .then(res => { + clipboardData.setData('text/plain', el.textContent); + clipboardData.setData('text/x-gfm', res); + clipboardData.setData('text/html', html); + }) + .catch(() => {}); } static pasteGFM(e) { @@ -137,11 +138,21 @@ export class CopyAsGFM { } static nodeToGFM(node) { - const wrapEl = document.createElement('div'); - wrapEl.appendChild(node.cloneNode(true)); - const doc = DOMParser.fromSchema(schema).parse(wrapEl); - - return markdownSerializer.serialize(doc); + return Promise.all([ + import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'), + import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'), + import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'), + ]) + .then(([prosemirrorModel, schema, markdownSerializer]) => { + const { DOMParser } = prosemirrorModel; + const wrapEl = document.createElement('div'); + wrapEl.appendChild(node.cloneNode(true)); + const doc = DOMParser.fromSchema(schema.default).parse(wrapEl); + + const res = markdownSerializer.default.serialize(doc); + return res; + }) + .catch(() => {}); } } diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 35f1bb6b080..7adccbb062f 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -28,16 +28,13 @@ MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function($form) { var mdText; - var markdownVersion; - var url; var preview = $form.find('.js-md-preview'); + var url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } mdText = $form.find('textarea.markdown-area').val(); - markdownVersion = $form.attr('data-markdown-version'); - url = this.versionedPreviewPath(preview.data('url'), markdownVersion); if (mdText.trim().length === 0) { preview.text(this.emptyMessage); @@ -67,16 +64,6 @@ MarkdownPreview.prototype.showPreview = function($form) { } }; -MarkdownPreview.prototype.versionedPreviewPath = function(markdownPreviewPath, markdownVersion) { - if (typeof markdownVersion === 'undefined') { - return markdownPreviewPath; - } - - return `${markdownPreviewPath}${ - markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' - }markdown_version=${markdownVersion}`; -}; - MarkdownPreview.prototype.fetchMarkdownPreview = function(text, url, success) { if (!url) { return; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 0eb067d4963..680f2031409 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -64,26 +64,30 @@ export default class ShortcutsIssuable extends Shortcuts { const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); const blockquoteEl = document.createElement('blockquote'); blockquoteEl.appendChild(el); - const text = CopyAsGFM.nodeToGFM(blockquoteEl); - - if (text.trim() === '') { - return false; - } - - // If replyField already has some content, add a newline before our quote - const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; - $replyField - .val((a, current) => `${current}${separator}${text}\n\n`) - .trigger('input') - .trigger('change'); - - // Trigger autosize - const event = document.createEvent('Event'); - event.initEvent('autosize:update', true, false); - $replyField.get(0).dispatchEvent(event); + CopyAsGFM.nodeToGFM(blockquoteEl) + .then(text => { + if (text.trim() === '') { + return false; + } + + // If replyField already has some content, add a newline before our quote + const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; + $replyField + .val((a, current) => `${current}${separator}${text}\n\n`) + .trigger('input') + .trigger('change'); + + // Trigger autosize + const event = document.createEvent('Event'); + event.initEvent('autosize:update', true, false); + $replyField.get(0).dispatchEvent(event); + + // Focus the input field + $replyField.focus(); - // Focus the input field - $replyField.focus(); + return false; + }) + .catch(() => {}); return false; } diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index b1f992c03ff..6ebd1ad109e 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -6,7 +6,13 @@ import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants'; +import { + APPLICATION_STATUS, + REQUEST_SUBMITTED, + REQUEST_FAILURE, + UPGRADE_REQUESTED, + UPGRADE_REQUEST_FAILURE, +} from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; @@ -120,11 +126,17 @@ export default class Clusters { addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); + eventHub.$on('upgradeApplication', data => this.upgradeApplication(data)); + eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId)); + eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId)); } removeListeners() { if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken); eventHub.$off('installApplication', this.installApplication); + eventHub.$off('upgradeApplication', this.upgradeApplication); + eventHub.$off('upgradeFailed', this.upgradeFailed); + eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess); } initPolling() { @@ -231,22 +243,33 @@ export default class Clusters { installApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED); this.store.updateAppProperty(appId, 'requestReason', null); + this.store.updateAppProperty(appId, 'statusReason', null); + + this.service.installApplication(appId, data.params).catch(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); + }); + } + + upgradeApplication(data) { + const appId = data.id; + this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED); + this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING); + this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId)); + } + + upgradeFailed(appId) { + this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE); + } - this.service - .installApplication(appId, data.params) - .then(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); - }) - .catch(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); - }); + dismissUpgradeSuccess(appId) { + this.store.updateAppProperty(appId, 'requestStatus', null); } destroy() { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index d4354dcfebd..5952e93b9a7 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -1,20 +1,24 @@ <script> /* eslint-disable vue/require-default-prop */ +import { GlLink } from '@gitlab/ui'; +import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import { s__, sprintf } from '../../locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; import { APPLICATION_STATUS, - REQUEST_LOADING, - REQUEST_SUCCESS, + REQUEST_SUBMITTED, REQUEST_FAILURE, + UPGRADE_REQUESTED, } from '../constants'; export default { components: { loadingButton, identicon, + TimeagoTooltip, + GlLink, }, props: { id: { @@ -59,6 +63,18 @@ export default { type: String, required: false, }, + version: { + type: String, + required: false, + }, + chartRepo: { + type: String, + required: false, + }, + upgradeAvailable: { + type: Boolean, + required: false, + }, installApplicationRequestParams: { type: Object, required: false, @@ -72,11 +88,31 @@ export default { isKnownStatus() { return Object.values(APPLICATION_STATUS).includes(this.status); }, + isInstalling() { + return ( + this.status === APPLICATION_STATUS.SCHEDULED || + this.status === APPLICATION_STATUS.INSTALLING || + (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled) + ); + }, isInstalled() { return ( this.status === APPLICATION_STATUS.INSTALLED || this.status === APPLICATION_STATUS.UPDATED || - this.status === APPLICATION_STATUS.UPDATING + this.status === APPLICATION_STATUS.UPDATING || + this.status === APPLICATION_STATUS.UPDATE_ERRORED + ); + }, + canInstall() { + if (this.isInstalling) { + return false; + } + + return ( + this.status === APPLICATION_STATUS.NOT_INSTALLABLE || + this.status === APPLICATION_STATUS.INSTALLABLE || + this.status === APPLICATION_STATUS.ERROR || + this.isUnknownStatus ); }, hasLogo() { @@ -90,12 +126,7 @@ export default { return `js-cluster-application-row-${this.id}`; }, installButtonLoading() { - return ( - !this.status || - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING || - this.requestStatus === REQUEST_LOADING - ); + return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling; }, installButtonDisabled() { // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but @@ -104,30 +135,17 @@ export default { return ( ((this.status !== APPLICATION_STATUS.INSTALLABLE && this.status !== APPLICATION_STATUS.ERROR) || - this.requestStatus === REQUEST_LOADING || - this.requestStatus === REQUEST_SUCCESS) && + this.isInstalling) && this.isKnownStatus ); }, installButtonLabel() { let label; - if ( - this.status === APPLICATION_STATUS.NOT_INSTALLABLE || - this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.ERROR || - this.isUnknownStatus - ) { + if (this.canInstall) { label = s__('ClusterIntegration|Install'); - } else if ( - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING - ) { + } else if (this.isInstalling) { label = s__('ClusterIntegration|Installing'); - } else if ( - this.status === APPLICATION_STATUS.INSTALLED || - this.status === APPLICATION_STATUS.UPDATED || - this.status === APPLICATION_STATUS.UPDATING - ) { + } else if (this.isInstalled) { label = s__('ClusterIntegration|Installed'); } @@ -140,13 +158,79 @@ export default { return s__('ClusterIntegration|Manage'); }, hasError() { - return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE; + return ( + !this.isInstalling && + (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE) + ); }, generalErrorDescription() { return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), { title: this.title, }); }, + versionLabel() { + if (this.upgradeFailed) { + return s__('ClusterIntegration|Upgrade failed'); + } else if (this.isUpgrading) { + return s__('ClusterIntegration|Upgrading'); + } + + return s__('ClusterIntegration|Upgraded'); + }, + upgradeRequested() { + return this.requestStatus === UPGRADE_REQUESTED; + }, + upgradeSuccessful() { + return this.status === APPLICATION_STATUS.UPDATED; + }, + upgradeFailed() { + if (this.isUpgrading) { + return false; + } + + return this.status === APPLICATION_STATUS.UPDATE_ERRORED; + }, + upgradeFailureDescription() { + return sprintf( + s__( + 'ClusterIntegration|Something went wrong when upgrading %{title}. Please check the logs and try again.', + ), + { + title: this.title, + }, + ); + }, + upgradeSuccessDescription() { + return sprintf(s__('ClusterIntegration|%{title} upgraded successfully.'), { + title: this.title, + }); + }, + upgradeButtonLabel() { + let label; + if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) { + label = s__('ClusterIntegration|Upgrade'); + } else if (this.isUpgrading) { + label = s__('ClusterIntegration|Upgrading'); + } else if (this.upgradeFailed) { + label = s__('ClusterIntegration|Retry upgrade'); + } + + return label; + }, + isUpgrading() { + // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend + return ( + this.status === APPLICATION_STATUS.UPDATING || + (this.upgradeRequested && !this.upgradeSuccessful) + ); + }, + }, + watch: { + status() { + if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) { + eventHub.$emit('upgradeFailed', this.id); + } + }, }, methods: { installClicked() { @@ -155,6 +239,15 @@ export default { params: this.installApplicationRequestParams, }); }, + upgradeClicked() { + eventHub.$emit('upgradeApplication', { + id: this.id, + params: this.installApplicationRequestParams, + }); + }, + dismissUpgradeSuccess() { + eventHub.$emit('dismissUpgradeSuccess', this.id); + }, }, }; </script> @@ -208,6 +301,51 @@ export default { </li> </ul> </div> + + <div + v-if="(upgradeSuccessful || upgradeFailed) && !upgradeAvailable" + class="form-text text-muted label p-0 js-cluster-application-upgrade-details" + > + {{ versionLabel }} + + <span v-if="upgradeSuccessful"> to</span> + + <gl-link + v-if="upgradeSuccessful" + :href="chartRepo" + target="_blank" + class="js-cluster-application-upgrade-version" + > + chart v{{ version }} + </gl-link> + </div> + + <div + v-if="upgradeFailed && !isUpgrading" + class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" + > + {{ upgradeFailureDescription }} + </div> + + <div + v-if="upgradeRequested && upgradeSuccessful" + class="bs-callout bs-callout-success cluster-application-banner mt-2 mb-0 p-0 pl-3" + > + {{ upgradeSuccessDescription }} + + <button class="close cluster-application-banner-close" @click="dismissUpgradeSuccess"> + × + </button> + </div> + + <loading-button + v-if="upgradeAvailable || upgradeFailed || isUpgrading" + class="btn btn-primary js-cluster-application-upgrade-button mt-2" + :loading="isUpgrading" + :disabled="isUpgrading" + :label="upgradeButtonLabel" + @click="upgradeClicked" + /> </div> <div :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 5d19c79570a..0cf187d4189 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -362,6 +362,9 @@ export default { :status-reason="applications.runner.statusReason" :request-status="applications.runner.requestStatus" :request-reason="applications.runner.requestReason" + :version="applications.runner.version" + :chart-repo="applications.runner.chartRepo" + :upgrade-available="applications.runner.upgradeAvailable" :disabled="!helmInstalled" title-link="https://docs.gitlab.com/runner/" > diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index e31afadf186..39022879d91 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -12,16 +12,19 @@ export const APPLICATION_STATUS = { SCHEDULED: 'scheduled', INSTALLING: 'installing', INSTALLED: 'installed', - UPDATED: 'updated', UPDATING: 'updating', + UPDATED: 'updated', + UPDATE_ERRORED: 'update_errored', ERROR: 'errored', }; // These are only used client-side -export const REQUEST_LOADING = 'request-loading'; -export const REQUEST_SUCCESS = 'request-success'; +export const REQUEST_SUBMITTED = 'request-submitted'; export const REQUEST_FAILURE = 'request-failure'; +export const UPGRADE_REQUESTED = 'upgrade-requested'; +export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure'; export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; +export const RUNNER = 'runner'; export const CERT_MANAGER = 'cert_manager'; diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 8f74be4e0e6..d309678be27 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,6 +1,6 @@ import { s__ } from '../../locale'; import { parseBoolean } from '../../lib/utils/common_utils'; -import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER } from '../constants'; +import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER, RUNNER } from '../constants'; export default class ClusterStore { constructor() { @@ -40,6 +40,9 @@ export default class ClusterStore { statusReason: null, requestStatus: null, requestReason: null, + version: null, + chartRepo: 'https://gitlab.com/charts/gitlab-runner', + upgradeAvailable: null, }, prometheus: { title: s__('ClusterIntegration|Prometheus'), @@ -100,7 +103,13 @@ export default class ClusterStore { this.state.statusReason = serverState.status_reason; serverState.applications.forEach(serverAppEntry => { - const { name: appId, status, status_reason: statusReason } = serverAppEntry; + const { + name: appId, + status, + status_reason: statusReason, + version, + update_available: upgradeAvailable, + } = serverAppEntry; this.state.applications[appId] = { ...(this.state.applications[appId] || {}), @@ -124,6 +133,9 @@ export default class ClusterStore { serverAppEntry.hostname || this.state.applications.knative.hostname; this.state.applications.knative.externalIp = serverAppEntry.external_ip || this.state.applications.knative.externalIp; + } else if (appId === RUNNER) { + this.state.applications.runner.version = version; + this.state.applications.runner.upgradeAvailable = upgradeAvailable; } }); } diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index a7ed175f7a4..009153d0703 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -7,4 +7,3 @@ import 'vendor/jquery.caret'; import 'vendor/jquery.atwho'; import 'vendor/jquery.scrollTo'; import 'jquery.waitforimages'; -import 'select2/select2'; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index d4c1b07093d..f0ce2579ee7 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -129,6 +129,10 @@ export default { created() { this.adjustView(); eventHub.$once('fetchedNotesData', this.setDiscussions); + eventHub.$once('fetchDiffData', this.fetchData); + }, + beforeDestroy() { + eventHub.$off('fetchDiffData', this.fetchData); }, methods: { ...mapActions(['startTaskList']), diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 3ef54752436..0bf2dde8b96 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -6,6 +6,7 @@ import { polyfillSticky } from '~/lib/utils/sticky'; import Icon from '~/vue_shared/components/icon.vue'; import CompareVersionsDropdown from './compare_versions_dropdown.vue'; import SettingsDropdown from './settings_dropdown.vue'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -14,6 +15,7 @@ export default { GlLink, GlButton, SettingsDropdown, + DiffStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,8 +37,15 @@ export default { }, }, computed: { - ...mapState('diffs', ['commit', 'showTreeList', 'startVersion', 'latestVersionPath']), - ...mapGetters('diffs', ['hasCollapsedFile']), + ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']), + ...mapState('diffs', [ + 'commit', + 'showTreeList', + 'startVersion', + 'latestVersionPath', + 'addedLines', + 'removedLines', + ]), comparableDiffs() { return this.mergeRequestDiffs.slice(1); }, @@ -104,6 +113,11 @@ export default { <gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link> </div> <div class="inline-parallel-buttons d-none d-md-flex ml-auto"> + <diff-stats + :diff-files-length="diffFilesLength" + :added-lines="addedLines" + :removed-lines="removedLines" + /> <gl-button v-if="commit || startVersion" :href="latestVersionPath" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index b58f704bebb..60586d4a607 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -9,6 +9,7 @@ import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; import EditButton from './edit_button.vue'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -16,6 +17,7 @@ export default { EditButton, Icon, FileIcon, + DiffStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -202,6 +204,7 @@ export default { v-if="!diffFile.submodule && addMergeRequestButtons" class="file-actions d-none d-sm-block" > + <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <template v-if="diffFile.blob && diffFile.blob.readable_text"> <button :disabled="!diffHasDiscussions(diffFile)" diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue new file mode 100644 index 00000000000..2e5855380af --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -0,0 +1,52 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { n__ } from '~/locale'; + +export default { + components: { Icon }, + props: { + addedLines: { + type: Number, + required: true, + }, + removedLines: { + type: Number, + required: true, + }, + diffFilesLength: { + type: Number, + required: false, + default: null, + }, + }, + computed: { + filesText() { + return n__('File', 'Files', this.diffFilesLength); + }, + isCompareVersionsHeader() { + return Boolean(this.diffFilesLength); + }, + }, +}; +</script> + +<template> + <div + class="diff-stats" + :class="{ + 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader, + 'd-inline-flex': !isCompareVersionsHeader, + }" + > + <div v-if="diffFilesLength !== null" class="diff-stats-group"> + <icon name="doc-code" class="diff-stats-icon text-secondary" /> + <strong>{{ diffFilesLength }} {{ filesText }}</strong> + </div> + <div class="diff-stats-group cgreen"> + <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong> + </div> + <div class="diff-stats-group cred"> + <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 0b3def3d29d..96ae197d8b8 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -13,39 +13,17 @@ export default { Icon, FileRow, }, - data() { - return { - search: '', - }; - }, computed: { - ...mapState('diffs', ['tree', 'addedLines', 'removedLines', 'renderTreeList']), - ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']), + ...mapState('diffs', ['tree', 'renderTreeList']), + ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { - const search = this.search.toLowerCase().trim(); - - if (search === '') return this.renderTreeList ? this.tree : this.allBlobs; - - return this.allBlobs.reduce((acc, folder) => { - const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0); - - if (tree.length) { - return acc.concat({ - ...folder, - tree, - }); - } - - return acc; - }, []); + return this.renderTreeList ? this.tree : this.allBlobs; }, }, methods: { - ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), - clearSearch() { - this.search = ''; - }, + ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']), }, + shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl'}+P`, FileRowStats, }; </script> @@ -55,21 +33,17 @@ export default { <div class="append-bottom-8 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <icon name="search" class="position-absolute tree-list-icon" /> - <input - v-model="search" - :placeholder="s__('MergeRequest|Filter files')" - type="search" - class="form-control" - /> <button - v-show="search" - :aria-label="__('Clear search')" type="button" - class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" - @click="clearSearch" + class="form-control text-left text-secondary" + @click="toggleFileFinder(true)" > - <icon name="close" /> + {{ s__('MergeRequest|Search files') }} </button> + <span + class="position-absolute text-secondary diff-tree-search-shortcut" + v-html="$options.shortcutKeyCharacter" + ></span> </div> </div> <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> @@ -90,13 +64,6 @@ export default { {{ s__('MergeRequest|No files found') }} </p> </div> - <div v-once class="pt-3 pb-3 text-center"> - {{ n__('%d changed file', '%d changed files', diffFilesLength) }} - <div> - <span class="cgreen"> {{ n__('%d addition', '%d additions', addedLines) }} </span> - <span class="cred"> {{ n__('%d deleted', '%d deletions', removedLines) }} </span> - </div> - </div> </div> </template> @@ -104,4 +71,15 @@ export default { .tree-list-blobs .file-row-name { margin-left: 12px; } + +.diff-tree-search-shortcut { + top: 50%; + right: 10px; + transform: translateY(-50%); + pointer-events: none; +} + +.tree-list-icon { + pointer-events: none; +} </style> diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 094e5cdea9c..63954d9d412 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,11 +1,60 @@ import Vue from 'vue'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; +import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; import { TREE_LIST_STORAGE_KEY } from './constants'; export default function initDiffsApp(store) { + const fileFinderEl = document.getElementById('js-diff-file-finder'); + + if (fileFinderEl) { + // eslint-disable-next-line no-new + new Vue({ + el: fileFinderEl, + store, + computed: { + ...mapState('diffs', ['fileFinderVisible', 'isLoading']), + ...mapGetters('diffs', ['flatBlobsList']), + }, + watch: { + fileFinderVisible(newVal, oldVal) { + if (newVal && !oldVal && !this.flatBlobsList.length) { + eventHub.$emit('fetchDiffData'); + } + }, + }, + methods: { + ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']), + openFile(file) { + window.mrTabs.tabShown('diffs'); + this.scrollToFile(file.path); + }, + }, + render(createElement) { + return createElement(FindFile, { + props: { + files: this.flatBlobsList, + visible: this.fileFinderVisible, + loading: this.isLoading, + showDiffStats: true, + clearSearchOnClose: false, + }, + on: { + toggle: this.toggleFileFinder, + click: this.openFile, + }, + class: ['diff-file-finder'], + style: { + display: this.fileFinderVisible ? '' : 'none', + }, + }); + }, + }); + } + return new Vue({ el: '#js-diffs-app', name: 'MergeRequestDiffs', diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 2c5019fb652..7fb66ce433b 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -296,5 +296,9 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals } }; +export const toggleFileFinder = ({ commit }, visible) => { + commit(types.TOGGLE_FILE_FINDER_VISIBLE, visible); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 86c0c7190f9..0e1ad654a2b 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -74,24 +74,25 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.file_hash === fileHash); -export const allBlobs = state => - Object.values(state.treeEntries) - .filter(f => f.type === 'blob') - .reduce((acc, file) => { - const { parentPath } = file; - - if (parentPath && !acc.some(f => f.path === parentPath)) { - acc.push({ - path: parentPath, - isHeader: true, - tree: [], - }); - } - - acc.find(f => f.path === parentPath).tree.push(file); - - return acc; - }, []); +export const flatBlobsList = state => + Object.values(state.treeEntries).filter(f => f.type === 'blob'); + +export const allBlobs = (state, getters) => + getters.flatBlobsList.reduce((acc, file) => { + const { parentPath } = file; + + if (parentPath && !acc.some(f => f.path === parentPath)) { + acc.push({ + path: parentPath, + isHeader: true, + tree: [], + }); + } + + acc.find(f => f.path === parentPath).tree.push(file); + + return acc; + }, []); export const diffFilesLength = state => state.diffFiles.length; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 05b4c552f6e..47f78a5db54 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -11,6 +11,8 @@ const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); export default () => ({ isLoading: true, + addedLines: null, + removedLines: null, endpoint: '', basePath: '', commit: null, @@ -29,4 +31,5 @@ export default () => ({ highlightedRow: null, renderTreeList: true, showWhitespace: true, + fileFinderVisible: false, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index e760b4d1079..71ad108ce88 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -22,3 +22,4 @@ export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW'; export const SET_TREE_DATA = 'SET_TREE_DATA'; export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST'; export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE'; +export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 4aeb393b29b..7bbafe66199 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -244,4 +244,7 @@ export default { [types.SET_SHOW_WHITESPACE](state, showWhitespace) { state.showWhitespace = showWhitespace; }, + [types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) { + state.fileFinderVisible = visible; + }, }; diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index dbfcf8cc921..cb1b1173190 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -64,6 +64,7 @@ class DueDateSelect { this.saveDueDate(true); } }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($dueDateInput.val())); @@ -183,6 +184,7 @@ export default class DueDateSelectors { onSelect(dateText) { $datePicker.val(calendar.toString(dateText)); }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate(datePickerVal)); diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index bd402c0eea5..6ece8b92a30 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -22,10 +22,6 @@ export default { type: Object, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -51,11 +47,7 @@ export default { <slot name="emptyState"></slot> <div v-if="!isLoading && environments.length > 0" class="table-holder"> - <environment-table - :environments="environments" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <environment-table :environments="environments" :can-read-environment="canReadEnvironment" /> <table-pagination v-if="pagination && pagination.totalPages > 1" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index f44806d82a6..503c1b38f71 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -47,12 +47,6 @@ export default { default: () => ({}), }, - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, - canReadEnvironment: { type: Boolean, required: false, @@ -151,7 +145,7 @@ export default { }, actions() { - if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) { + if (!this.model || !this.model.last_deployment) { return []; } @@ -561,7 +555,7 @@ export default { /> <rollback-component - v-if="canRetry && canCreateDeployment" + v-if="canRetry" :is-last-deployment="isLastDeployment" :retry-url="retryUrl" /> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 87c1d44dd40..aa2417d3194 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -24,10 +24,6 @@ export default { type: Boolean, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -106,7 +102,6 @@ export default { :is-loading="isLoading" :environments="state.environments" :pagination="state.paginationInformation" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" @onChangePage="onChangePage" > diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 75bdf87137f..e2c304de00a 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -23,12 +23,6 @@ export default { required: false, default: false, }, - - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, }, methods: { folderUrl(model) { @@ -64,7 +58,6 @@ export default { is="environment-item" :key="`environment-item-${i}`" :model="model" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" /> @@ -79,7 +72,6 @@ export default { v-for="(children, index) in model.children" :key="`env-item-${i}-${index}`" :model="children" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" /> diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 982e550e73c..56e7f69cad6 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -18,7 +18,6 @@ export default () => endpoint: environmentsData.environmentsDataEndpoint, folderName: environmentsData.environmentsDataFolderName, cssContainerClass: environmentsData.cssClass, - canCreateDeployment: parseBoolean(environmentsData.environmentsDataCanCreateDeployment), canReadEnvironment: parseBoolean(environmentsData.environmentsDataCanReadEnvironment), }; }, @@ -28,7 +27,6 @@ export default () => endpoint: this.endpoint, folderName: this.folderName, cssContainerClass: this.cssContainerClass, - canCreateDeployment: this.canCreateDeployment, canReadEnvironment: this.canReadEnvironment, }, }); diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index d6f0b6115a6..80f0e00400b 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -23,10 +23,6 @@ export default { type: String, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -55,7 +51,6 @@ export default { :is-loading="isLoading" :environments="state.environments" :pagination="state.paginationInformation" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" @onChangePage="onChangePage" /> diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index d366e7550b7..6af66d0f86e 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -20,7 +20,6 @@ export default () => helpPagePath: environmentsData.helpPagePath, cssContainerClass: environmentsData.cssClass, canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), - canCreateDeployment: parseBoolean(environmentsData.canCreateDeployment), canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment), }; }, @@ -32,7 +31,6 @@ export default () => helpPagePath: this.helpPagePath, cssContainerClass: this.cssContainerClass, canCreateEnvironment: this.canCreateEnvironment, - canCreateDeployment: this.canCreateDeployment, canReadEnvironment: this.canReadEnvironment, }, }); diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 96dc1f07cb9..e81a1525df0 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -143,7 +143,7 @@ export default { */ created() { this.service = new EnvironmentsService(this.endpoint); - this.requestData = { page: this.page, scope: this.scope }; + this.requestData = { page: this.page, scope: this.scope, nested: true }; this.poll = new Poll({ resource: this.service, diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 4e07ccba91a..cb4ff6856db 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -7,8 +7,8 @@ export default class EnvironmentsService { } fetchEnvironments(options = {}) { - const { scope, page } = options; - return axios.get(this.environmentsEndpoint, { params: { scope, page } }); + const { scope, page, nested } = options; + return axios.get(this.environmentsEndpoint, { params: { scope, page, nested } }); } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 5808a2d4afa..ac9a31c202c 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -20,7 +20,8 @@ export default class EnvironmentsStore { * * Stores the received environments. * - * In the main environments endpoint, each environment has the following schema + * In the main environments endpoint (with { nested: true } in params), each folder + * has the following schema: * { name: String, size: Number, latest: Object } * In the endpoint to retrieve environments from each folder, the environment does * not have the `latest` key and the data is all in the root level. diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 2049760fe29..bdadbb1bb2a 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -4,93 +4,97 @@ import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; export default function groupsSelect() { - // Needs to be accessible in rspec - window.GROUP_SELECT_PER_PAGE = 20; - $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { - const $select = $(this); - const allAvailable = $select.data('allAvailable'); - const skipGroups = $select.data('skipGroups') || []; - const parentGroupID = $select.data('parentId'); - const groupsPath = parentGroupID - ? Api.subgroupsPath.replace(':id', parentGroupID) - : Api.groupsPath; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + // Needs to be accessible in rspec + window.GROUP_SELECT_PER_PAGE = 20; + $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { + const $select = $(this); + const allAvailable = $select.data('allAvailable'); + const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; - $select.select2({ - placeholder: 'Search for a group', - allowClear: $select.hasClass('allowClear'), - multiple: $select.hasClass('multiselect'), - minimumInputLength: 0, - ajax: { - url: Api.buildUrl(groupsPath), - dataType: 'json', - quietMillis: 250, - transport(params) { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then(res => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; + $select.select2({ + placeholder: 'Search for a group', + allowClear: $select.hasClass('allowClear'), + multiple: $select.hasClass('multiselect'), + minimumInputLength: 0, + ajax: { + url: Api.buildUrl(groupsPath), + dataType: 'json', + quietMillis: 250, + transport(params) { + axios[params.type.toLowerCase()](params.url, { + params: params.data, + }) + .then(res => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const currentPage = parseInt(headers['X-PAGE'], 10) || 0; + const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; + const more = currentPage < totalPages; - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); - }, - data(search, page) { - return { - search, - page, - per_page: window.GROUP_SELECT_PER_PAGE, - all_available: allAvailable, - }; - }, - results(data, page) { - if (data.length) return { results: [] }; + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); + }, + data(search, page) { + return { + search, + page, + per_page: window.GROUP_SELECT_PER_PAGE, + all_available: allAvailable, + }; + }, + results(data, page) { + if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; - const more = data.pagination ? data.pagination.more : false; - const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); + const groups = data.length ? data : data.results || []; + const more = data.pagination ? data.pagination.more : false; + const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); - return { - results, - page, - more, - }; - }, - }, - // eslint-disable-next-line consistent-return - initSelection(element, callback) { - const id = $(element).val(); - if (id !== '') { - return Api.group(id, callback); - } - }, - formatResult(object) { - return `<div class='group-result'> <div class='group-name'>${ - object.full_name - }</div> <div class='group-path'>${object.full_path}</div> </div>`; - }, - formatSelection(object) { - return object.full_name; - }, - dropdownCssClass: 'ajax-groups-dropdown select2-infinite', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); + return { + results, + page, + more, + }; + }, + }, + // eslint-disable-next-line consistent-return + initSelection(element, callback) { + const id = $(element).val(); + if (id !== '') { + return Api.group(id, callback); + } + }, + formatResult(object) { + return `<div class='group-result'> <div class='group-name'>${ + object.full_name + }</div> <div class='group-path'>${object.full_path}</div> </div>`; + }, + formatSelection(object) { + return object.full_name; + }, + dropdownCssClass: 'ajax-groups-dropdown select2-infinite', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); - $select.on('select2-loaded', () => { - const dropdown = document.querySelector('.select2-infinite .select2-results'); - dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; - }); - }); + $select.on('select2-loaded', () => { + const dropdown = document.querySelector('.select2-infinite .select2-results'); + dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; + }); + }); + }) + .catch(() => {}); } diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index caec8779cac..9894ebb0624 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,20 +1,17 @@ <script> import Vue from 'vue'; -import Mousetrap from 'mousetrap'; import { mapActions, mapState, mapGetters } from 'vuex'; import { __ } from '~/locale'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; import RepoTabs from './repo_tabs.vue'; import IdeStatusBar from './ide_status_bar.vue'; import RepoEditor from './repo_editor.vue'; -import FindFile from './file_finder/index.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; -const originalStopCallback = Mousetrap.stopCallback; - export default { components: { NewModal, @@ -42,21 +39,18 @@ export default { 'emptyStateSvgPath', 'currentProjectId', 'errorMessage', + 'loading', + ]), + ...mapGetters([ + 'activeFile', + 'hasChanges', + 'someUncommittedChanges', + 'isCommitModeActive', + 'allBlobs', ]), - ...mapGetters(['activeFile', 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive']), }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); - - Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { - if (e.preventDefault) { - e.preventDefault(); - } - - this.toggleFileFinder(!this.fileFindVisible); - }); - - Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); }, methods: { ...mapActions(['toggleFileFinder']), @@ -70,17 +64,8 @@ export default { }); return returnValue; }, - mousetrapStopCallback(e, el, combo) { - if ( - (combo === 't' && el.classList.contains('dropdown-input-field')) || - el.classList.contains('inputarea') - ) { - return true; - } else if (combo === 'command+p' || combo === 'ctrl+p') { - return false; - } - - return originalStopCallback(e, el, combo); + openFile(file) { + this.$router.push(`/project${file.url}`); }, }, }; @@ -90,7 +75,14 @@ export default { <article class="ide position-relative d-flex flex-column align-items-stretch"> <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> - <find-file v-show="fileFindVisible" /> + <find-file + v-show="fileFindVisible" + :files="allBlobs" + :visible="fileFindVisible" + :loading="loading" + @toggle="toggleFileFinder" + @click="openFile" + /> <ide-sidebar /> <div class="multi-file-edit-pane"> <template v-if="activeFile"> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index f1d40586903..ce577ae85b0 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -107,16 +107,23 @@ export default { class="commit-sha" >{{ lastCommit.short_id }}</a > - by {{ lastCommit.author_name }} + by + <user-avatar-image + css-classes="ide-status-avatar" + :size="18" + :img-src="latestPipeline && latestPipeline.commit.author_gravatar_url" + :img-alt="lastCommit.author_name" + :tooltip-text="lastCommit.author_name" + /> + {{ lastCommit.author_name }} <time v-tooltip :datetime="lastCommit.committed_date" :title="tooltipTitle(lastCommit.committed_date)" data-placement="top" data-container="body" + >{{ lastCommitFormatedAge }}</time > - {{ lastCommitFormatedAge }} - </time> </div> <div v-if="file" class="ide-status-file">{{ file.name }}</div> <div v-if="file" class="ide-status-file">{{ file.eol }}</div> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 09245ed0296..804ebae4555 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -1,8 +1,3 @@ -// Fuzzy file finder -export const MAX_FILE_FINDER_RESULTS = 40; -export const FILE_FINDER_ROW_HEIGHT = 55; -export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; - export const MAX_WINDOW_HEIGHT_COMPACT = 750; // Commit message textarea diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index d97a950a8b2..24c2f71ae2b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -202,7 +202,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch( 'setErrorMessage', { - text: __('An error accured whilst committing your changes.'), + text: __('An error occurred whilst committing your changes.'), action: () => dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true }), diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js index 612c524ca1c..e0fb58ef195 100644 --- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -11,10 +11,14 @@ class AutoWidthDropdownSelect { init() { const { dropdownClass } = this; - this.$selectElement.select2({ - dropdownCssClass: dropdownClass, - ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), - }); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), + }); + }) + .catch(() => {}); return this; } diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index f3d722409b0..48e7ed1318d 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -7,10 +7,14 @@ export default class IssuableContext { constructor(currentUser) { this.userSelect = new UsersSelect(currentUser); - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + }) + .catch(() => {}); $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() { return $(this).submit(); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index c81a2230310..9336b71cfd7 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -44,6 +44,7 @@ export default class IssuableForm { parse: dateString => parsePikadayDate(dateString), toString: date => pikadayToString(date), onSelect: dateText => $issuableDueDate.val(calendar.toString(dateText)), + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($issuableDueDate.val())); } @@ -120,35 +121,39 @@ export default class IssuableForm { } initTargetBranchDropdown() { - this.$targetBranchSelect.select2({ - ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), - ajax: { - url: this.$targetBranchSelect.data('endpoint'), - dataType: 'JSON', - quietMillis: 250, - data(search) { - return { - search, - }; - }, - results(data) { - return { - // `data` keys are translated so we can't just access them with a string based key - results: data[Object.keys(data)[0]].map(name => ({ - id: name, - text: name, - })), - }; - }, - }, - initSelection(el, callback) { - const val = el.val(); - - callback({ - id: val, - text: val, + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + this.$targetBranchSelect.select2({ + ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), + ajax: { + url: this.$targetBranchSelect.data('endpoint'), + dataType: 'JSON', + quietMillis: 250, + data(search) { + return { + search, + }; + }, + results(data) { + return { + // `data` keys are translated so we can't just access them with a string based key + results: data[Object.keys(data)[0]].map(name => ({ + id: name, + text: name, + })), + }; + }, + }, + initSelection(el, callback) { + const val = el.val(); + + callback({ + id: val, + text: val, + }); + }, }); - }, - }); + }) + .catch(() => {}); } } diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index cd569eb3045..bd757a76ee7 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -1,5 +1,7 @@ <script> import Visibility from 'visibilityjs'; +import { __, s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; import { visitUrl } from '../../lib/utils/url_utility'; import Poll from '../../lib/utils/poll'; import eventHub from '../event_hub'; @@ -10,7 +12,6 @@ import descriptionComponent from './description.vue'; import editedComponent from './edited.vue'; import formComponent from './form.vue'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; -import { __ } from '~/locale'; export default { components: { @@ -107,11 +108,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, projectPath: { type: String, required: true, @@ -130,6 +126,11 @@ export default { required: false, default: true, }, + lockVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { const store = new Store({ @@ -141,6 +142,7 @@ export default { updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, taskStatus: this.initialTaskStatus, + lock_version: this.lockVersion, }); return { @@ -161,6 +163,9 @@ export default { const titleChanged = this.initialTitleText !== this.store.formState.title; return descriptionChanged || titleChanged; }, + defaultErrorMessage() { + return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); + }, }, created() { this.service = new Service(this.endpoint); @@ -207,6 +212,17 @@ export default { } return undefined; }, + updateStoreState() { + return this.service + .getData() + .then(res => res.data) + .then(data => { + this.store.updateState(data); + }) + .catch(() => { + createFlash(this.defaultErrorMessage); + }); + }, openForm() { if (!this.showForm) { @@ -214,6 +230,7 @@ export default { this.store.setFormState({ title: this.state.titleText, description: this.state.descriptionText, + lock_version: this.state.lock_version, lockedWarningVisible: false, updateLoading: false, }); @@ -232,20 +249,24 @@ export default { if (window.location.pathname !== data.web_url) { visitUrl(data.web_url); } - - return this.service.getData(); }) - .then(res => res.data) - .then(data => { - this.store.updateState(data); + .then(this.updateStoreState) + .then(() => { eventHub.$emit('close.form'); }) - .catch(error => { - if (error && error.name === 'SpamError') { + .catch((error = {}) => { + const { name, response = {} } = error; + + if (name === 'SpamError') { this.openRecaptcha(); } else { - eventHub.$emit('close.form'); - window.Flash(`Error updating ${this.issuableType}`); + let errMsg = this.defaultErrorMessage; + + if (response.data && response.data.errors) { + errMsg += `. ${response.data.errors.join(' ')}`; + } + + createFlash(errMsg); } }); }, @@ -269,8 +290,9 @@ export default { visitUrl(data.web_url); }) .catch(() => { - eventHub.$emit('close.form'); - window.Flash(`Error deleting ${this.issuableType}`); + createFlash( + sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }), + ); }); }, }, @@ -286,7 +308,6 @@ export default { :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" - :markdown-version="markdownVersion" :project-path="projectPath" :project-namespace="projectNamespace" :show-delete-button="showDeleteButton" @@ -314,6 +335,8 @@ export default { :task-status="state.taskStatus" :issuable-type="issuableType" :update-url="updateEndpoint" + :lock-version="state.lock_version" + @taskListUpdateFailed="updateStoreState" /> <edited-component v-if="hasUpdated" diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 5ca88d75063..58f14bac8c8 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,5 +1,7 @@ <script> import $ from 'jquery'; +import { s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; import animateMixin from '../mixins/animate'; import TaskList from '../../task_list'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; @@ -35,6 +37,11 @@ export default { required: false, default: null, }, + lockVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -67,8 +74,10 @@ export default { new TaskList({ dataType: this.issuableType, fieldName: 'description', + lockVersion: this.lockVersion, selector: '.detail-page-description', onSuccess: this.taskListUpdateSuccess.bind(this), + onError: this.taskListUpdateError.bind(this), }); } }, @@ -82,6 +91,21 @@ export default { } }, + taskListUpdateError() { + createFlash( + sprintf( + s__( + 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', + ), + { + issueType: this.issuableType, + }, + ), + ); + + this.$emit('taskListUpdateFailed'); + }, + updateTaskStatusText() { const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); const $issuableHeader = $('.issuable-meta'); diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 90258c0e044..299130e56ae 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -20,11 +20,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, canAttachFile: { type: Boolean, required: false, @@ -48,7 +43,6 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" > diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 45b60bc3392..eade31f1d14 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, projectPath: { type: String, required: true, @@ -101,7 +96,6 @@ export default { :form-state="formState" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" /> diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 32044d6da25..3c17e73ccec 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -1,3 +1,5 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + export default class Store { constructor(initialState) { this.state = initialState; @@ -6,6 +8,7 @@ export default class Store { description: '', lockedWarningVisible: false, updateLoading: false, + lock_version: 0, }; } @@ -14,14 +17,10 @@ export default class Store { this.formState.lockedWarningVisible = true; } + Object.assign(this.state, convertObjectPropsToCamelCase(data)); this.state.titleHtml = data.title; - this.state.titleText = data.title_text; this.state.descriptionHtml = data.description; - this.state.descriptionText = data.description_text; - this.state.taskStatus = data.task_status; - this.state.updatedAt = data.updated_at; - this.state.updatedByName = data.updated_by_name; - this.state.updatedByPath = data.updated_by_path; + this.state.lock_version = data.lock_version; } stateShouldUpdate(data) { diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 91332c21b52..c5076d65ff9 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -39,7 +39,9 @@ export default { <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> <span class="font-weight-bold">{{ __('Pipeline') }}</span> - <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a> + <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path" + >#{{ pipeline.id }}</a + > <template v-if="hasRef"> {{ __('from') }} <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a> diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index 062501d1d04..f134a54dd53 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -70,7 +70,18 @@ export default class LabelManager { const $detachedLabel = $label.detach(); this.toggleLabelPriorityBadge($detachedLabel, action); - $detachedLabel.appendTo($target); + + const $labelEls = $target.find('li.label-list-item'); + + /* + * If there is a label element in the target, we'd want to + * append the new label just right next to it. + */ + if ($labelEls.length) { + $labelEls.last().after($detachedLabel); + } else { + $detachedLabel.appendTo($target); + } if ($from.find('li').length) { $from.find('.empty-message').removeClass('hidden'); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 3b6a57dad44..0ceff10a02a 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -221,6 +221,22 @@ export const scrollToElement = element => { }; /** + * Returns a function that can only be invoked once between + * each browser screen repaint. + * @param {Function} fn + */ +export const debounceByAnimationFrame = fn => { + let requestId; + + return function debounced(...args) { + if (requestId) { + window.cancelAnimationFrame(requestId); + } + requestId = window.requestAnimationFrame(() => fn.apply(this, args)); + }; +}; + +/** this will take in the `name` of the param you want to parse in the url if the name does not exist this function will return `null` otherwise it will return the value of the param key provided @@ -614,10 +630,18 @@ export const spriteIcon = (icon, className = '') => { /** * This method takes in object with snake_case property names - * and returns new object with camelCase property names + * and returns a new object with camelCase property names * * Reasoning for this method is to ensure consistent property * naming conventions across JS code. + * + * This method also supports additional params in `options` object + * + * @param {Object} obj - Object to be converted. + * @param {Object} options - Object containing additional options. + * @param {boolean} options.deep - FLag to allow deep object converting + * @param {Array[]} dropKeys - List of properties to discard while building new object + * @param {Array[]} ignoreKeyNames - List of properties to leave intact (as snake_case) while building new object */ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { if (obj === null) { @@ -625,12 +649,26 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { } const initial = Array.isArray(obj) ? [] : {}; + const { deep = false, dropKeys = [], ignoreKeyNames = [] } = options; return Object.keys(obj).reduce((acc, prop) => { const result = acc; const val = obj[prop]; - if (options.deep && (isObject(val) || Array.isArray(val))) { + // Drop properties from new object if + // there are any mentioned in options + if (dropKeys.indexOf(prop) > -1) { + return acc; + } + + // Skip converting properties in new object + // if there are any mentioned in options + if (ignoreKeyNames.indexOf(prop) > -1) { + result[prop] = obj[prop]; + return acc; + } + + if (deep && (isObject(val) || Array.isArray(val))) { result[convertToCamelCase(prop)] = convertObjectPropsToCamelCase(val, options); } else { result[convertToCamelCase(prop)] = obj[prop]; diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js index b41ffb44971..82ee83e4348 100644 --- a/app/assets/javascripts/lib/utils/file_upload.js +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -1,6 +1,9 @@ export default (buttonSelector, fileSelector) => { const btn = document.querySelector(buttonSelector); const fileInput = document.querySelector(fileSelector); + + if (!btn || !fileInput) return; + const form = btn.closest('form'); btn.addEventListener('click', () => { diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js new file mode 100644 index 00000000000..18f9e2ed846 --- /dev/null +++ b/app/assets/javascripts/lib/utils/grammar.js @@ -0,0 +1,40 @@ +import { sprintf, s__ } from '~/locale'; + +/** + * Combines each given item into a noun series sentence fragment. It does this + * in a way that supports i18n by giving context and punctuation to the locale + * functions. + * + * **Examples:** + * + * - `["A", "B"] => "A and B"` + * - `["A", "B", "C"] => "A, B, and C"` + * + * **Why only nouns?** + * + * Some languages need a bit more context to translate other series. + * + * @param {String[]} items + */ +export const toNounSeriesText = items => { + if (items.length === 0) { + return ''; + } else if (items.length === 1) { + return items[0]; + } else if (items.length === 2) { + return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), { + firstItem: items[0], + lastItem: items[1], + }); + } + + return items.reduce((item, nextItem, idx) => + idx === items.length - 1 + ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }) + : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }), + ); +}; + +export default { + toNounSeriesText, +}; diff --git a/app/assets/javascripts/lib/utils/icon_utils.js b/app/assets/javascripts/lib/utils/icon_utils.js new file mode 100644 index 00000000000..7b8dd9bbef7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/icon_utils.js @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ + +import axios from '~/lib/utils/axios_utils'; + +/** + * Retrieve SVG icon path content from gitlab/svg sprite icons + * @param {String} name + */ +export const getSvgIconPathContent = name => + axios + .get(gon.sprite_icons) + .then(({ data: svgs }) => + new DOMParser() + .parseFromString(svgs, 'text/xml') + .querySelector(`#${name} path`) + .getAttribute('d'), + ) + .catch(() => null); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 4ba3543f9b2..63db4938cd7 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -100,27 +100,30 @@ function deferredInitialisation() { }); // Initialize select2 selects - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); - - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - setTimeout(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }, 1); - }); + if ($('select.select2').length) { + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + + // Close select2 on escape + $('.js-select2').on('select2-close', () => { + setTimeout(() => { + $('.select2-container-active').removeClass('select2-container-active'); + $(':focus').blur(); + }, 1); + }); + }) + .catch(() => {}); + } // Initialize tooltips $body.tooltip({ selector: '.has-tooltip, [data-toggle="tooltip"]', trigger: 'hover', boundary: 'viewport', - placement(tip, el) { - return $(el).data('placement') || 'bottom'; - }, }); // Initialize popovers diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js index 0beedcacf33..0dabb28ea66 100644 --- a/app/assets/javascripts/member_expiration_date.js +++ b/app/assets/javascripts/member_expiration_date.js @@ -33,6 +33,7 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d toggleClearInput.call($input); }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($input.val())); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 0deae478deb..3b42a154af8 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; +import createFlash from '~/flash'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; @@ -35,10 +36,18 @@ function MergeRequest(opts) { dataType: 'merge_request', fieldName: 'description', selector: '.detail-page-description', + lockVersion: this.$el.data('lockVersion'), onSuccess: result => { document.querySelector('#task_status').innerText = result.task_status; document.querySelector('#task_status_short').innerText = result.task_status_short; }, + onError: () => { + createFlash( + __( + 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', + ), + ); + }, }); } } diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index e2cffe0b4b4..14c02db7bcc 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,10 +1,16 @@ <script> -import { GlAreaChart } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import Icon from '~/vue_shared/components/icon.vue'; + +let debouncedResize; export default { components: { GlAreaChart, + Icon, }, inheritAttrs: false, props: { @@ -26,22 +32,38 @@ export default { ); }, }, + containerWidth: { + type: Number, + required: true, + }, + deploymentData: { + type: Array, + required: false, + default: () => [], + }, alertData: { type: Object, required: false, default: () => ({}), }, }, + data() { + return { + tooltip: { + title: '', + content: '', + isDeployment: false, + sha: '', + }, + width: 0, + height: 0, + scatterSymbol: undefined, + }; + }, computed: { chartData() { return this.graphData.queries.reduce((accumulator, query) => { - const xLabel = `${query.unit}`; - accumulator[xLabel] = {}; - query.result.forEach(res => - res.values.forEach(v => { - accumulator[xLabel][v.time.toISOString()] = v.value; - }), - ); + accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); return accumulator; }, {}); }, @@ -51,14 +73,17 @@ export default { name: 'Time', type: 'time', axisLabel: { - formatter: date => dateFormat(date, 'h:MMtt'), + formatter: date => dateFormat(date, 'h:MM TT'), + }, + axisPointer: { + snap: true, }, nameTextStyle: { padding: [18, 0, 0, 0], }, }, yAxis: { - name: this.graphData.y_label, + name: this.yAxisLabel, axisLabel: { formatter: value => value.toFixed(3), }, @@ -69,33 +94,129 @@ export default { legend: { formatter: this.xAxisLabel, }, + series: this.scatterSeries, + }; + }, + earliestDatapoint() { + return Object.values(this.chartData).reduce((acc, data) => { + const [[timestamp]] = data.sort(([a], [b]) => { + if (a < b) { + return -1; + } + return a > b ? 1 : 0; + }); + + return timestamp < acc || acc === null ? timestamp : acc; + }, null); + }, + recentDeployments() { + return this.deploymentData.reduce((acc, deployment) => { + if (deployment.created_at >= this.earliestDatapoint) { + acc.push({ + id: deployment.id, + createdAt: deployment.created_at, + sha: deployment.sha, + commitUrl: `${this.projectPath}/commit/${deployment.sha}`, + tag: deployment.tag, + tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null, + ref: deployment.ref.name, + showDeploymentFlag: false, + }); + } + + return acc; + }, []); + }, + scatterSeries() { + return { + type: 'scatter', + data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]), + symbol: this.scatterSymbol, + symbolSize: 14, }; }, xAxisLabel() { return this.graphData.queries.map(query => query.label).join(', '); }, + yAxisLabel() { + const [query] = this.graphData.queries; + return `${this.graphData.y_label} (${query.unit})`; + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + this.getScatterSymbol(); }, methods: { formatTooltipText(params) { - const [date, value] = params; - return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)]; + const [seriesData] = params.seriesData; + this.tooltip.isDeployment = seriesData.componentSubType === 'scatter'; + this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); + if (this.tooltip.isDeployment) { + const [deploy] = this.recentDeployments.filter( + deployment => deployment.createdAt === seriesData.value[0], + ); + this.tooltip.sha = deploy.sha.substring(0, 8); + } else { + this.tooltip.content = `${this.yAxisLabel} ${seriesData.value[1].toFixed(3)}`; + } + }, + getScatterSymbol() { + getSvgIconPathContent('rocket') + .then(path => { + if (path) { + this.scatterSymbol = `path://${path}`; + } + }) + .catch(() => {}); + }, + onResize() { + const { width, height } = this.$refs.areaChart.$el.getBoundingClientRect(); + this.width = width; + this.height = height; }, }, }; </script> <template> - <div class="prometheus-graph"> + <div class="prometheus-graph col-12 col-lg-6"> <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> - <div class="prometheus-graph-widgets"><slot></slot></div> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> </div> <gl-area-chart + ref="areaChart" v-bind="$attrs" :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" :thresholds="alertData" - /> + :width="width" + :height="height" + > + <template slot="tooltipTitle"> + <div v-if="tooltip.isDeployment"> + {{ __('Deployed') }} + </div> + {{ tooltip.title }} + </template> + <template slot="tooltipContent"> + <div v-if="tooltip.isDeployment" class="d-flex align-items-center"> + <icon name="commit" class="mr-2" /> + {{ tooltip.sha }} + </div> + <template v-else> + {{ tooltip.content }} + </template> + </template> + </gl-area-chart> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index cea5c1a56ca..895a57785bc 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,20 +1,19 @@ <script> -import _ from 'underscore'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import MonitoringService from '../services/monitoring_service'; import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; -import Graph from './graph.vue'; import EmptyState from './empty_state.vue'; import MonitoringStore from '../stores/monitoring_store'; -import eventHub from '../event_hub'; + +const sidebarAnimationDuration = 150; +let sidebarMutationObserver; export default { components: { MonitorAreaChart, - Graph, GraphGroup, EmptyState, Icon, @@ -25,21 +24,11 @@ export default { required: false, default: true, }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, showPanels: { type: Boolean, required: false, default: true, }, - forceSmallGraph: { - type: Boolean, - required: false, - default: false, - }, documentationPath: { type: String, required: true, @@ -99,48 +88,32 @@ export default { store: new MonitoringStore(), state: 'gettingStarted', showEmptyState: true, - hoverData: {}, elWidth: 0, }; }, - computed: { - graphComponent() { - return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph; - }, - forceRedraw() { - return this.elWidth; - }, - }, created() { this.service = new MonitoringService({ metricsEndpoint: this.metricsEndpoint, deploymentEndpoint: this.deploymentEndpoint, environmentsEndpoint: this.environmentsEndpoint, }); - this.mutationObserverConfig = { - attributes: true, - childList: false, - subtree: false, - }; - eventHub.$on('hoverChanged', this.hoverChanged); }, beforeDestroy() { - eventHub.$off('hoverChanged', this.hoverChanged); - window.removeEventListener('resize', this.resizeThrottled, false); - this.sidebarMutationObserver.disconnect(); + if (sidebarMutationObserver) { + sidebarMutationObserver.disconnect(); + } }, mounted() { - this.resizeThrottled = _.debounce(this.resize, 100); if (!this.hasMetrics) { this.state = 'gettingStarted'; } else { this.getGraphsData(); - window.addEventListener('resize', this.resizeThrottled, false); - - const sidebarEl = document.querySelector('.nav-sidebar'); - // The sidebar listener - this.sidebarMutationObserver = new MutationObserver(this.resizeThrottled); - this.sidebarMutationObserver.observe(sidebarEl, this.mutationObserverConfig); + sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); + sidebarMutationObserver.observe(document.querySelector('.layout-page'), { + attributes: true, + childList: false, + subtree: false, + }); } }, methods: { @@ -168,42 +141,40 @@ export default { this.showEmptyState = false; }) - .then(this.resize) .catch(() => { this.state = 'unableToConnect'; }); }, - resize() { - this.elWidth = this.$el.clientWidth; - }, - hoverChanged(data) { - this.hoverData = data; + onSidebarMutation() { + setTimeout(() => { + this.elWidth = this.$el.clientWidth; + }, sidebarAnimationDuration); }, }, }; </script> <template> - <div v-if="!showEmptyState" :key="forceRedraw" class="prometheus-graphs prepend-top-default"> + <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default"> <div class="environments d-flex align-items-center"> {{ s__('Metrics|Environment') }} <div class="dropdown prepend-left-10"> <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> - <span> {{ currentEnvironmentName }} </span> <icon name="chevron-down" /> + <span>{{ currentEnvironmentName }}</span> + <icon name="chevron-down" /> </button> <div v-if="store.environmentsData.length > 0" class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up" > <ul> - <li v-for="environment in store.environmentsData" :key="environment.latest.id"> + <li v-for="environment in store.environmentsData" :key="environment.id"> <a - :href="environment.latest.metrics_path" - :class="{ 'is-active': environment.latest.name == currentEnvironmentName }" + :href="environment.metrics_path" + :class="{ 'is-active': environment.name == currentEnvironmentName }" class="dropdown-item" + >{{ environment.name }}</a > - {{ environment.latest.name }} - </a> </li> </ul> </div> @@ -215,23 +186,15 @@ export default { :name="groupData.group" :show-panels="showPanels" > - <component - :is="graphComponent" + <monitor-area-chart v-for="(graphData, graphIndex) in groupData.metrics" :key="graphIndex" :graph-data="graphData" - :hover-data="hoverData" :deployment-data="store.deploymentData" - :project-path="projectPath" - :tags-path="tagsPath" - :show-legend="showLegend" - :small-graph="forceSmallGraph" :alert-data="getGraphAlerts(graphData.id)" + :container-width="elWidth" group-id="monitor-area-chart" - > - <!-- EE content --> - {{ null }} - </component> + /> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue deleted file mode 100644 index 309b73f5a4d..00000000000 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ /dev/null @@ -1,329 +0,0 @@ -<script> -import { scaleLinear, scaleTime } from 'd3-scale'; -import { axisLeft, axisBottom } from 'd3-axis'; -import _ from 'underscore'; -import { max, extent } from 'd3-array'; -import { select } from 'd3-selection'; -import GraphAxis from './graph/axis.vue'; -import GraphLegend from './graph/legend.vue'; -import GraphFlag from './graph/flag.vue'; -import GraphDeployment from './graph/deployment.vue'; -import GraphPath from './graph/path.vue'; -import MonitoringMixin from '../mixins/monitoring_mixins'; -import eventHub from '../event_hub'; -import measurements from '../utils/measurements'; -import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters'; -import createTimeSeries from '../utils/multiple_time_series'; -import bp from '../../breakpoints'; - -const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }; - -export default { - components: { - GraphAxis, - GraphFlag, - GraphDeployment, - GraphPath, - GraphLegend, - }, - mixins: [MonitoringMixin], - props: { - graphData: { - type: Object, - required: true, - }, - deploymentData: { - type: Array, - required: true, - }, - hoverData: { - type: Object, - required: false, - default: () => ({}), - }, - projectPath: { - type: String, - required: true, - }, - tagsPath: { - type: String, - required: true, - }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, - smallGraph: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - baseGraphHeight: 450, - baseGraphWidth: 600, - graphHeight: 450, - graphWidth: 600, - graphHeightOffset: 120, - margin: {}, - unitOfDisplay: '', - yAxisLabel: '', - legendTitle: '', - reducedDeploymentData: [], - measurements: measurements.large, - currentData: { - time: new Date(), - value: 0, - }, - currentXCoordinate: 0, - currentCoordinates: {}, - showFlag: false, - showFlagContent: false, - timeSeries: [], - graphDrawData: {}, - realPixelRatio: 1, - seriesUnderMouse: [], - }; - }, - computed: { - outerViewBox() { - return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; - }, - innerViewBox() { - return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`; - }, - axisTransform() { - return `translate(70, ${this.graphHeight - 100})`; - }, - paddingBottomRootSvg() { - return { - paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`, - }; - }, - deploymentFlagData() { - return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); - }, - shouldRenderData() { - return this.graphData.queries.filter(s => s.result.length > 0).length > 0; - }, - }, - watch: { - hoverData() { - this.positionFlag(); - }, - }, - mounted() { - this.draw(); - }, - methods: { - showDot(path) { - return this.showFlagContent && this.seriesUnderMouse.includes(path); - }, - draw() { - const breakpointSize = bp.getBreakpointSize(); - const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width; - - this.margin = measurements.large.margin; - - if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { - this.graphHeight = 300; - this.margin = measurements.small.margin; - this.measurements = measurements.small; - } - - this.yAxisLabel = this.graphData.y_label || 'Values'; - this.graphWidth = svgWidth - this.margin.left - this.margin.right; - this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - this.baseGraphHeight = this.graphHeight - 50; - this.baseGraphWidth = this.graphWidth; - - // pixel offsets inside the svg and outside are not 1:1 - this.realPixelRatio = svgWidth / this.baseGraphWidth; - - // set the legends on the axes - const [query] = this.graphData.queries; - this.legendTitle = query ? query.label : 'Average'; - this.unitOfDisplay = query ? query.unit : ''; - - if (this.shouldRenderData) { - this.renderAxesPaths(); - this.formatDeployments(); - } - }, - handleMouseOverGraph(e) { - let point = this.$refs.graphData.createSVGPoint(); - point.x = e.clientX; - point.y = e.clientY; - point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); - point.x += 7; - - this.seriesUnderMouse = this.timeSeries.filter(series => { - const mouseX = series.timeSeriesScaleX.invert(point.x); - let minDistance = Infinity; - - const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => { - const distance = Math.abs(Number(new Date(x)) - Number(mouseX)); - if (distance < minDistance) { - minDistance = distance; - return x; - } - return closest; - }); - - return series.values.find(v => v.time.toString() === closestTickMark); - }); - - const firstTimeSeries = this.seriesUnderMouse[0]; - const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); - const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); - const d0 = firstTimeSeries.values[overlayIndex - 1]; - const d1 = firstTimeSeries.values[overlayIndex]; - if (d0 === undefined || d1 === undefined) return; - const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; - const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1; - const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time; - const currentDeployXPos = this.mouseOverDeployInfo(point.x); - - eventHub.$emit('hoverChanged', { - hoveredDate, - currentDeployXPos, - }); - }, - renderAxesPaths() { - ({ timeSeries: this.timeSeries, graphDrawData: this.graphDrawData } = createTimeSeries( - this.graphData.queries, - this.graphWidth, - this.graphHeight, - this.graphHeightOffset, - )); - - if (_.findWhere(this.timeSeries, { renderCanary: true })) { - this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true })); - } - - const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); - const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]); - - const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); - axisXScale.domain(d3.extent(allValues, d => d.time)); - axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); - - this.allXAxisValues = this.timeSeries.reduce((obj, series) => { - const seriesKeys = {}; - series.values.forEach(v => { - seriesKeys[v.time] = true; - }); - return { - ...obj, - ...seriesKeys, - }; - }, {}); - - const xAxis = d3 - .axisBottom() - .scale(axisXScale) - .ticks(this.graphWidth / 120) - .tickFormat(timeScaleFormat); - - const yAxis = d3 - .axisLeft() - .scale(axisYScale) - .ticks(measurements.yTicks); - - d3.select(this.$refs.baseSvg) - .select('.x-axis') - .call(xAxis); - - const width = this.graphWidth; - d3.select(this.$refs.baseSvg) - .select('.y-axis') - .call(yAxis) - .selectAll('.tick') - .each(function createTickLines(d, i) { - if (i > 0) { - d3.select(this) - .select('line') - .attr('x2', width) - .attr('class', 'axis-tick'); - } // Avoid adding the class to the first tick, to prevent coloring - }); // This will select all of the ticks once they're rendered - }, - }, -}; -</script> - -<template> - <div - class="prometheus-graph" - @mouseover="showFlagContent = true" - @mouseleave="showFlagContent = false" - > - <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> - <div class="prometheus-graph-widgets"><slot></slot></div> - </div> - <div :style="paddingBottomRootSvg" class="prometheus-svg-container"> - <svg ref="baseSvg" :viewBox="outerViewBox"> - <g :transform="axisTransform" class="x-axis" /> - <g class="y-axis" transform="translate(70, 20)" /> - <graph-axis - :graph-width="graphWidth" - :graph-height="graphHeight" - :margin="margin" - :measurements="measurements" - :y-axis-label="yAxisLabel" - :unit-of-display="unitOfDisplay" - /> - <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data"> - <slot name="additionalSvgContent" :graphDrawData="graphDrawData" /> - <graph-path - v-for="(path, index) in timeSeries" - :key="index" - :generated-line-path="path.linePath" - :generated-area-path="path.areaPath" - :line-style="path.lineStyle" - :line-color="path.lineColor" - :area-color="path.areaColor" - :current-coordinates="currentCoordinates[path.metricTag]" - :show-dot="showDot(path)" - /> - <graph-deployment - :deployment-data="reducedDeploymentData" - :graph-height="graphHeight" - :graph-height-offset="graphHeightOffset" - /> - <rect - ref="graphOverlay" - :width="graphWidth - 70" - :height="graphHeight - 100" - class="prometheus-graph-overlay" - transform="translate(-5, 20)" - @mousemove="handleMouseOverGraph($event)" - /> - </svg> - <svg v-else :viewBox="innerViewBox" class="js-no-data-to-display"> - <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle"> - {{ s__('Metrics|No data to display') }} - </text> - </svg> - </svg> - <graph-flag - v-if="shouldRenderData" - :real-pixel-ratio="realPixelRatio" - :current-x-coordinate="currentXCoordinate" - :current-data="currentData" - :graph-height="graphHeight" - :graph-height-offset="graphHeightOffset" - :show-flag-content="showFlagContent" - :time-series="seriesUnderMouse" - :unit-of-display="unitOfDisplay" - :legend-title="legendTitle" - :deployment-flag-data="deploymentFlagData" - :current-coordinates="currentCoordinates" - /> - </div> - <graph-legend v-if="showLegend" :legend-title="legendTitle" :time-series="timeSeries" /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue deleted file mode 100644 index 8f046857a20..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/axis.vue +++ /dev/null @@ -1,118 +0,0 @@ -<script> -import { convertToSentenceCase } from '~/lib/utils/text_utility'; -import { s__ } from '~/locale'; - -export default { - props: { - graphWidth: { - type: Number, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - margin: { - type: Object, - required: true, - }, - measurements: { - type: Object, - required: true, - }, - yAxisLabel: { - type: String, - required: true, - }, - unitOfDisplay: { - type: String, - required: true, - }, - }, - data() { - return { - yLabelWidth: 0, - yLabelHeight: 0, - }; - }, - computed: { - textTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0; - - return `translate(15, ${yCoordinate}) rotate(-90)`; - }, - - rectTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 + - this.yLabelWidth / 2 || 0; - - return `translate(0, ${yCoordinate}) rotate(-90)`; - }, - - xPosition() { - return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0; - }, - - yPosition() { - return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0; - }, - - yAxisLabelSentenceCase() { - return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`; - }, - - timeString() { - return s__('PrometheusDashboard|Time'); - }, - }, - mounted() { - this.$nextTick(() => { - const bbox = this.$refs.ylabel.getBBox(); - this.yLabelWidth = bbox.width + 10; // Added some padding - this.yLabelHeight = bbox.height + 5; - }); - }, -}; -</script> -<template> - <g class="axis-label-container"> - <line - :y1="yPosition" - :x2="graphWidth + 20" - :y2="yPosition" - class="label-x-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - /> - <line - :x2="10" - :y2="yPosition" - class="label-y-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - y1="0" - /> - <rect - :transform="rectTransform" - :width="yLabelWidth" - :height="yLabelHeight" - class="rect-axis-text" - /> - <text - ref="ylabel" - :transform="textTransform" - class="label-axis-text y-label-text" - text-anchor="middle" - > - {{ yAxisLabelSentenceCase }} - </text> - <rect :x="xPosition + 60" :y="graphHeight - 80" class="rect-axis-text" width="35" height="50" /> - <text :x="xPosition + 60" :y="yPosition" class="label-axis-text x-label-text" dy=".35em"> - {{ timeString }} - </text> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue deleted file mode 100644 index bee9784692c..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/deployment.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -export default { - props: { - deploymentData: { - type: Array, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - graphHeightOffset: { - type: Number, - required: true, - }, - }, - computed: { - calculatedHeight() { - return this.graphHeight - this.graphHeightOffset; - }, - }, - methods: { - transformDeploymentGroup(deployment) { - return `translate(${Math.floor(deployment.xPos) - 5}, 20)`; - }, - }, -}; -</script> -<template> - <g class="deploy-info"> - <g - v-for="(deployment, index) in deploymentData" - :key="index" - :transform="transformDeploymentGroup(deployment)" - > - <rect :height="calculatedHeight" x="0" y="0" width="3" fill="url(#shadow-gradient)" /> - <line :y2="calculatedHeight" class="deployment-line" x1="0" y1="0" x2="0" stroke="#000" /> - </g> - <svg height="0" width="0"> - <defs> - <linearGradient id="shadow-gradient"> - <stop offset="0%" stop-color="#000" stop-opacity="0.4" /> - <stop offset="100%" stop-color="#000" stop-opacity="0" /> - </linearGradient> - </defs> - </svg> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue deleted file mode 100644 index 9d6d1caef80..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ /dev/null @@ -1,151 +0,0 @@ -<script> -import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; -import { formatRelevantDigits } from '../../../lib/utils/number_utils'; -import Icon from '../../../vue_shared/components/icon.vue'; -import TrackLine from './track_line.vue'; - -export default { - components: { - Icon, - TrackLine, - }, - props: { - currentXCoordinate: { - type: Number, - required: true, - }, - currentData: { - type: Object, - required: true, - }, - deploymentFlagData: { - type: Object, - required: false, - default: null, - }, - graphHeight: { - type: Number, - required: true, - }, - graphHeightOffset: { - type: Number, - required: true, - }, - realPixelRatio: { - type: Number, - required: true, - }, - showFlagContent: { - type: Boolean, - required: true, - }, - timeSeries: { - type: Array, - required: true, - }, - unitOfDisplay: { - type: String, - required: true, - }, - legendTitle: { - type: String, - required: true, - }, - currentCoordinates: { - type: Object, - required: true, - }, - }, - computed: { - formatTime() { - return this.deploymentFlagData - ? timeFormat(this.deploymentFlagData.time) - : timeFormat(this.currentData.time); - }, - formatDate() { - return this.deploymentFlagData - ? dateFormat(this.deploymentFlagData.time) - : dateFormat(this.currentData.time); - }, - cursorStyle() { - const xCoordinate = this.deploymentFlagData - ? this.deploymentFlagData.xPos - : this.currentXCoordinate; - - const offsetTop = 20 * this.realPixelRatio; - const offsetLeft = (70 + xCoordinate) * this.realPixelRatio; - const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio; - - return { - top: `${offsetTop}px`, - left: `${offsetLeft}px`, - height: `${height}px`, - }; - }, - flagOrientation() { - if (this.currentXCoordinate * this.realPixelRatio > 120) { - return 'left'; - } - return 'right'; - }, - }, - methods: { - seriesMetricValue(seriesIndex, series) { - const indexFromCoordinates = this.currentCoordinates[series.metricTag] - ? this.currentCoordinates[series.metricTag].currentDataIndex - : 0; - const index = this.deploymentFlagData - ? this.deploymentFlagData.seriesIndex - : indexFromCoordinates; - const value = series.values[index] && series.values[index].value; - if (Number.isNaN(value)) { - return '-'; - } - return `${formatRelevantDigits(value)}${this.unitOfDisplay}`; - }, - seriesMetricLabel(index, series) { - if (this.timeSeries.length < 2) { - return this.legendTitle; - } - if (series.metricTag) { - return series.metricTag; - } - return `series ${index + 1}`; - }, - }, -}; -</script> - -<template> - <div :style="cursorStyle" class="prometheus-graph-cursor"> - <div v-if="showFlagContent" :class="flagOrientation" class="prometheus-graph-flag popover"> - <div class="arrow-shadow"></div> - <div class="arrow"></div> - <div class="popover-title"> - <h5 v-if="deploymentFlagData">Deployed</h5> - {{ formatDate }} <strong>{{ formatTime }}</strong> - </div> - <div v-if="deploymentFlagData" class="popover-content deploy-meta-content"> - <div> - <icon :size="12" name="commit" /> - <a :href="deploymentFlagData.commitUrl"> {{ deploymentFlagData.sha.slice(0, 8) }} </a> - </div> - <div v-if="deploymentFlagData.tag"> - <icon :size="12" name="label" /> - <a :href="deploymentFlagData.tagUrl"> {{ deploymentFlagData.ref }} </a> - </div> - </div> - <div class="popover-content"> - <table class="prometheus-table"> - <tr v-for="(series, index) in timeSeries" :key="index"> - <track-line :track="series" /> - <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td> - <td> - <strong>{{ seriesMetricValue(index, series) }}</strong> - </td> - </tr> - </table> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue deleted file mode 100644 index b5211c306a3..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ /dev/null @@ -1,62 +0,0 @@ -<script> -import TrackLine from './track_line.vue'; -import TrackInfo from './track_info.vue'; - -export default { - components: { - TrackLine, - TrackInfo, - }, - props: { - legendTitle: { - type: String, - required: true, - }, - timeSeries: { - type: Array, - required: true, - }, - }, - methods: { - isStable(track) { - return { - 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary, - }; - }, - }, -}; -</script> -<template> - <div class="prometheus-graph-legends prepend-left-10"> - <table class="prometheus-table"> - <tr - v-for="(series, index) in timeSeries" - v-if="series.shouldRenderLegend" - :key="index" - :class="isStable(series)" - > - <td> - <strong v-if="series.renderCanary">{{ series.trackName }}</strong> - </td> - <track-line :track="series" /> - <td v-if="timeSeries.length > 1" class="legend-metric-title"> - <track-info v-if="series.metricTag" :track="series" /> - <track-info v-else :track="series"> - <strong>{{ legendTitle }}</strong> series {{ index + 1 }} - </track-info> - </td> - <td v-else> - <track-info :track="series"> - <strong>{{ legendTitle }}</strong> - </track-info> - </td> - <template v-for="(track, trackIndex) in series.tracksLegend"> - <track-line :key="`track-line-${trackIndex}`" :track="track" /> - <td :key="`track-info-${trackIndex}`"> - <track-info :track="track" class="legend-metric-title" /> - </td> - </template> - </tr> - </table> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue deleted file mode 100644 index f2c237ec391..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/path.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -export default { - props: { - generatedLinePath: { - type: String, - required: true, - }, - generatedAreaPath: { - type: String, - required: true, - }, - lineStyle: { - type: String, - required: false, - default: '', - }, - lineColor: { - type: String, - required: true, - }, - areaColor: { - type: String, - required: true, - }, - currentCoordinates: { - type: Object, - required: false, - default: () => ({ currentX: 0, currentY: 0 }), - }, - showDot: { - type: Boolean, - required: true, - }, - }, - computed: { - strokeDashArray() { - if (this.lineStyle === 'dashed') return '3, 1'; - if (this.lineStyle === 'dotted') return '1, 1'; - return null; - }, - }, -}; -</script> -<template> - <g transform="translate(-5, 20)"> - <circle - v-if="showDot" - :cx="currentCoordinates.currentX" - :cy="currentCoordinates.currentY" - :fill="lineColor" - :stroke="lineColor" - class="circle-path" - r="3" - /> - <path :d="generatedAreaPath" :fill="areaColor" class="metric-area" /> - <path - :d="generatedLinePath" - :stroke="lineColor" - :stroke-dasharray="strokeDashArray" - class="metric-line" - fill="none" - stroke-width="1" - /> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue deleted file mode 100644 index 3464067834f..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/track_info.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import { formatRelevantDigits } from '~/lib/utils/number_utils'; - -export default { - name: 'TrackInfo', - props: { - track: { - type: Object, - required: true, - }, - }, - computed: { - summaryMetrics() { - return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits( - this.track.max, - )}`; - }, - }, -}; -</script> -<template> - <span> - <slot> - <strong> {{ track.metricTag }} </strong> - </slot> - {{ summaryMetrics }} - </span> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue deleted file mode 100644 index d2ed1ba113e..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/track_line.vue +++ /dev/null @@ -1,33 +0,0 @@ -<script> -export default { - name: 'TrackLine', - props: { - track: { - type: Object, - required: true, - }, - }, - computed: { - stylizedLine() { - if (this.track.lineStyle === 'dashed') return '6, 3'; - if (this.track.lineStyle === 'dotted') return '3, 3'; - return null; - }, - }, -}; -</script> -<template> - <td> - <svg width="16" height="8"> - <line - :stroke-dasharray="stylizedLine" - :stroke="track.lineColor" - :x1="0" - :x2="16" - :y1="4" - :y2="4" - stroke-width="4" - /> - </svg> - </td> -</template> diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/monitoring/event_hub.js deleted file mode 100644 index 0948c2e5352..00000000000 --- a/app/assets/javascripts/monitoring/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import Vue from 'vue'; - -export default new Vue(); diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js deleted file mode 100644 index 87c3d969de4..00000000000 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ /dev/null @@ -1,86 +0,0 @@ -import { bisectDate } from '../utils/date_time_formatters'; - -const mixins = { - methods: { - mouseOverDeployInfo(mouseXPos) { - if (!this.reducedDeploymentData) return false; - - let dataFound = false; - this.reducedDeploymentData = this.reducedDeploymentData.map(d => { - const deployment = d; - if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { - dataFound = d.xPos + 1; - - deployment.showDeploymentFlag = true; - } else { - deployment.showDeploymentFlag = false; - } - return deployment; - }); - - return dataFound; - }, - - formatDeployments() { - this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => { - const time = new Date(deployment.created_at); - const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time)); - - time.setSeconds(this.timeSeries[0].values[0].time.getSeconds()); - - if (xPos >= 0) { - const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1); - - deploymentDataArray.push({ - id: deployment.id, - time, - sha: deployment.sha, - commitUrl: `${this.projectPath}/commit/${deployment.sha}`, - tag: deployment.tag, - tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null, - ref: deployment.ref.name, - xPos, - seriesIndex, - showDeploymentFlag: false, - }); - } - - return deploymentDataArray; - }, []); - }, - - positionFlag() { - const timeSeries = this.seriesUnderMouse[0]; - if (!timeSeries) { - return; - } - const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate); - - this.currentData = timeSeries.values[hoveredDataIndex]; - this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time)); - - this.currentCoordinates = {}; - - this.seriesUnderMouse.forEach(series => { - const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate); - const currentData = series.values[currentDataIndex]; - const currentX = Math.floor(series.timeSeriesScaleX(currentData.time)); - const currentY = Math.floor(series.timeSeriesScaleY(currentData.value)); - - this.currentCoordinates[series.metricTag] = { - currentX, - currentY, - currentDataIndex, - }; - }); - - if (this.hoverData.currentDeployXPos) { - this.showFlag = false; - } else { - this.showFlag = true; - } - }, - }, -}; - -export default mixins; diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 8692c873a41..70635059bd9 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -13,7 +13,7 @@ function checkQueryEmptyData(query) { result: query.result.filter(timeSeries => { const newTimeSeries = timeSeries; const hasValue = series => - !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined); + !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined); const hasNonNullValue = timeSeries.values.find(hasValue); newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; @@ -33,10 +33,10 @@ function normalizeMetrics(metrics) { ...query, result: query.result.map(result => ({ ...result, - values: result.values.map(([timestamp, value]) => ({ - time: new Date(timestamp * 1000), - value: Number(value), - })), + values: result.values.map(([timestamp, value]) => [ + new Date(timestamp * 1000).toISOString(), + Number(value), + ]), })), })); @@ -66,9 +66,7 @@ export default class MonitoringStore { } storeEnvironmentsData(environmentsData = []) { - this.environmentsData = environmentsData.filter( - environment => !!environment.latest.last_deployment, - ); + this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment); } getMetricsCount() { diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js deleted file mode 100644 index d88c13609dc..00000000000 --- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js +++ /dev/null @@ -1,42 +0,0 @@ -import { timeFormat as time } from 'd3-time-format'; -import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time'; -import { bisector } from 'd3-array'; - -const d3 = { - time, - bisector, - timeSecond, - timeMinute, - timeHour, - timeDay, - timeWeek, - timeMonth, - timeYear, -}; - -export const dateFormat = d3.time('%d %b %Y, '); -export const timeFormat = d3.time('%-I:%M%p'); -export const dateFormatWithName = d3.time('%a, %b %-d'); -export const bisectDate = d3.bisector(d => d.time).left; - -export function timeScaleFormat(date) { - let formatFunction; - if (d3.timeSecond(date) < date) { - formatFunction = d3.time('.%L'); - } else if (d3.timeMinute(date) < date) { - formatFunction = d3.time(':%S'); - } else if (d3.timeHour(date) < date) { - formatFunction = d3.time('%-I:%M'); - } else if (d3.timeDay(date) < date) { - formatFunction = d3.time('%-I %p'); - } else if (d3.timeWeek(date) < date) { - formatFunction = d3.time('%a %d'); - } else if (d3.timeMonth(date) < date) { - formatFunction = d3.time('%b %d'); - } else if (d3.timeYear(date) < date) { - formatFunction = d3.time('%B'); - } else { - formatFunction = d3.time('%Y'); - } - return formatFunction(date); -} diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js deleted file mode 100644 index 7c771f43eee..00000000000 --- a/app/assets/javascripts/monitoring/utils/measurements.js +++ /dev/null @@ -1,44 +0,0 @@ -export default { - small: { - // Covers both xs and sm screen sizes - margin: { - top: 40, - right: 40, - bottom: 50, - left: 40, - }, - legends: { - width: 15, - height: 3, - offsetX: 20, - offsetY: 32, - }, - backgroundLegend: { - width: 30, - height: 50, - }, - axisLabelLineOffset: -20, - }, - large: { - // This covers both md and lg screen sizes - margin: { - top: 80, - right: 80, - bottom: 100, - left: 80, - }, - legends: { - width: 15, - height: 3, - offsetX: 20, - offsetY: 34, - }, - backgroundLegend: { - width: 30, - height: 150, - }, - axisLabelLineOffset: 20, - }, - xTicks: 8, - yTicks: 3, -}; diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js deleted file mode 100644 index 50ba14dfb2e..00000000000 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ /dev/null @@ -1,223 +0,0 @@ -import _ from 'underscore'; -import { scaleLinear, scaleTime } from 'd3-scale'; -import { line, area, curveLinear } from 'd3-shape'; -import { extent, max, sum } from 'd3-array'; -import { timeMinute, timeSecond } from 'd3-time'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; - -const d3 = { - scaleLinear, - scaleTime, - line, - area, - curveLinear, - extent, - max, - timeMinute, - timeSecond, - sum, -}; - -const defaultColorPalette = { - blue: ['#1f78d1', '#8fbce8'], - orange: ['#fc9403', '#feca81'], - red: ['#db3b21', '#ed9d90'], - green: ['#1aaa55', '#8dd5aa'], - purple: ['#6666c4', '#d1d1f0'], -}; - -const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple']; - -const defaultStyleOrder = ['solid', 'dashed', 'dotted']; - -function queryTimeSeries(query, graphDrawData, lineStyle) { - let usedColors = []; - let renderCanary = false; - const timeSeriesParsed = []; - - function pickColor(name) { - let pick; - if (name && defaultColorPalette[name]) { - pick = name; - } else { - const unusedColors = _.difference(defaultColorOrder, usedColors); - if (unusedColors.length > 0) { - [pick] = unusedColors; - } else { - usedColors = []; - [pick] = defaultColorOrder; - } - } - usedColors.push(pick); - return defaultColorPalette[pick]; - } - - function findByDate(series, time) { - const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60); - if (val) { - return val.value; - } - return NaN; - } - - // The timeseries data may have gaps in it - // but we need a regularly-spaced set of time/value pairs - // this gives us a complete range of one minute intervals - // offset the same amount as the original data - const [minX, maxX] = graphDrawData.xDom; - const offset = d3.timeMinute(minX) - Number(minX); - const datesWithoutGaps = d3.timeSecond - .every(60) - .range(d3.timeMinute.offset(minX, -1), maxX) - .map(d => d - offset); - - query.result.forEach((timeSeries, timeSeriesNumber) => { - let metricTag = ''; - let lineColor = ''; - let areaColor = ''; - let shouldRenderLegend = true; - const timeSeriesValues = timeSeries.values.map(d => d.value); - const maximumValue = d3.max(timeSeriesValues); - const accum = d3.sum(timeSeriesValues); - const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable'); - - if (trackName === 'Canary') { - renderCanary = true; - } - - const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; - const seriesCustomizationData = - query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); - - if (seriesCustomizationData) { - metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; - [lineColor, areaColor] = pickColor(seriesCustomizationData.color); - if (timeSeriesParsed.length > 0) { - shouldRenderLegend = false; - } else { - shouldRenderLegend = true; - } - } else { - metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; - [lineColor, areaColor] = pickColor(); - if (timeSeriesParsed.length > 1) { - shouldRenderLegend = false; - } - } - - const values = datesWithoutGaps.map(time => ({ - time, - value: findByDate(timeSeries.values, time), - })); - - timeSeriesParsed.push({ - linePath: graphDrawData.lineFunction(values), - areaPath: graphDrawData.areaBelowLine(values), - timeSeriesScaleX: graphDrawData.timeSeriesScaleX, - timeSeriesScaleY: graphDrawData.timeSeriesScaleY, - values: timeSeries.values, - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - areaColor, - metricTag, - trackName, - shouldRenderLegend, - renderCanary, - }); - - if (!shouldRenderLegend) { - if (!timeSeriesParsed[0].tracksLegend) { - timeSeriesParsed[0].tracksLegend = []; - } - timeSeriesParsed[0].tracksLegend.push({ - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - metricTag, - }); - } - }); - - return timeSeriesParsed; -} - -function xyDomain(queries) { - const allValues = queries.reduce( - (allQueryResults, query) => - allQueryResults.concat( - query.result.reduce((allResults, result) => allResults.concat(result.values), []), - ), - [], - ); - - const xDom = d3.extent(allValues, d => d.time); - const yDom = [0, d3.max(allValues.map(d => d.value))]; - - return { - xDom, - yDom, - }; -} - -export function generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset) { - const { xDom, yDom } = xyDomain(queries); - - const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]); - const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]); - - timeSeriesScaleX.domain(xDom); - timeSeriesScaleX.ticks(d3.timeMinute, 60); - timeSeriesScaleY.domain(yDom); - - const defined = d => !Number.isNaN(d.value) && d.value != null; - - const lineFunction = d3 - .line() - .defined(defined) - .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate - .x(d => timeSeriesScaleX(d.time)) - .y(d => timeSeriesScaleY(d.value)); - - const areaBelowLine = d3 - .area() - .defined(defined) - .curve(d3.curveLinear) - .x(d => timeSeriesScaleX(d.time)) - .y0(graphHeight - graphHeightOffset) - .y1(d => timeSeriesScaleY(d.value)); - - const areaAboveLine = d3 - .area() - .defined(defined) - .curve(d3.curveLinear) - .x(d => timeSeriesScaleX(d.time)) - .y0(0) - .y1(d => timeSeriesScaleY(d.value)); - - return { - lineFunction, - areaBelowLine, - areaAboveLine, - xDom, - yDom, - timeSeriesScaleX, - timeSeriesScaleY, - }; -} - -export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { - const graphDrawData = generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset); - - const timeSeries = queries.reduce((series, query, index) => { - const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length]; - return series.concat(queryTimeSeries(query, graphDrawData, lineStyle)); - }, []); - - return { - timeSeries, - graphDrawData, - }; -} diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index c3443c300e3..c9c01354333 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1239,15 +1239,13 @@ export default class Notes { var postUrl = $originalContentEl.data('postUrl'); var targetId = $originalContentEl.data('targetId'); var targetType = $originalContentEl.data('targetType'); - var markdownVersion = $originalContentEl.data('markdownVersion'); this.glForm = new GLForm($editForm.find('form'), this.enableGFM); $editForm .find('form') .attr('action', `${postUrl}?html=true`) - .attr('data-remote', 'true') - .attr('data-markdown-version', markdownVersion); + .attr('data-remote', 'true'); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); $editForm diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index d669ba5a8fa..1d6cb9485f7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, }, data() { return { @@ -342,7 +337,6 @@ Please check your network connection and try again.`; :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" - :markdown-version="markdownVersion" :add-spacing-classes="false" > <textarea diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue new file mode 100644 index 00000000000..ea590905e3c --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -0,0 +1,17 @@ +<script> +export default { + name: 'ReplyPlaceholder', +}; +</script> + +<template> + <button + ref="button" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + :title="s__('MergeRequests|Add a reply')" + @click="$emit('onClick')" + > + {{ s__('MergeRequests|Reply...') }} + </button> +</template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index d99694b06e9..cad0d382fa2 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -2,11 +2,13 @@ import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import ReplyButton from './note_actions/reply_button.vue'; export default { name: 'NoteActions', components: { Icon, + ReplyButton, GlLoadingIcon, }, directives: { @@ -21,6 +23,11 @@ export default { type: [String, Number], required: true, }, + discussionId: { + type: String, + required: false, + default: '', + }, noteUrl: { type: String, required: false, @@ -36,6 +43,10 @@ export default { required: false, default: null, }, + showReply: { + type: Boolean, + required: true, + }, canEdit: { type: Boolean, required: true, @@ -80,6 +91,9 @@ export default { }, computed: { ...mapGetters(['getUserDataByProp']), + showReplyButton() { + return gon.features && gon.features.replyToIndividualNotes && this.showReply; + }, shouldShowActionsDropdown() { return this.currentUserId && (this.canEdit || this.canReportAsAbuse); }, @@ -137,10 +151,9 @@ export default { </div> <div v-if="canAwardEmoji" class="note-actions-item"> <a - v-gl-tooltip.bottom + v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByCurrentUser }" class="note-action-button note-emoji-button js-add-award js-note-emoji" - data-position="right" href="#" title="Add reaction" > @@ -153,9 +166,15 @@ export default { <icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" /> </a> </div> + <reply-button + v-if="showReplyButton" + ref="replyButton" + class="js-reply-button" + :note-id="discussionId" + /> <div v-if="canEdit" class="note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="Edit comment" class="note-action-button js-note-edit btn btn-transparent" @@ -166,7 +185,7 @@ export default { </div> <div v-if="showDeleteAction" class="note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="Delete comment" class="note-action-button js-note-delete btn btn-transparent" @@ -177,7 +196,7 @@ export default { </div> <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="More actions" class="note-action-button more-actions-toggle btn btn-transparent" diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue new file mode 100644 index 00000000000..b2f9d7f128a --- /dev/null +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -0,0 +1,40 @@ +<script> +import { mapActions } from 'vuex'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReplyButton', + components: { + Icon, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + noteId: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions(['convertToDiscussion']), + }, +}; +</script> + +<template> + <div class="note-actions-item"> + <gl-button + ref="button" + v-gl-tooltip.bottom + class="note-action-button" + variant="transparent" + :title="__('Reply to comment')" + @click="convertToDiscussion(noteId)" + > + <icon name="comment" css-classes="link-highlight" /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 3efdd1c5c17..17e5fcab5b7 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -171,7 +171,6 @@ export default { :class="getAwardClassBindings(awardList)" :title="awardTitle(awardList)" data-boundary="viewport" - data-placement="bottom" class="btn award-control" type="button" @click="handleAward(awardName)" @@ -187,7 +186,6 @@ export default { title="Add reaction" aria-label="Add reaction" data-boundary="viewport" - data-placement="bottom" type="button" > <span class="award-control-icon award-control-icon-neutral"> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index bcf5d334da4..ff303d0f55a 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -111,7 +111,6 @@ export default { :line="line" :note="note" :help-page-path="helpPagePath" - :markdown-version="note.cached_markdown_version" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" /> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 269b4a4b117..92258a25438 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -26,11 +26,6 @@ export default { required: false, default: '', }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, saveButtonTitle: { type: String, required: false, @@ -202,7 +197,6 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" :line="line" :note="discussionNote" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 695efe3602f..b7e9f7c2028 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -24,6 +24,7 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; +import ReplyPlaceholder from './discussion_reply_placeholder.vue'; import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue'; export default { @@ -39,6 +40,7 @@ export default { resolveDiscussionButton, jumpToNextDiscussionButton, toggleRepliesWidget, + ReplyPlaceholder, placeholderNote, placeholderSystemNote, systemNote, @@ -396,6 +398,7 @@ Please check your network connection and try again.`; :line="line" :commit="commit" :help-page-path="helpPagePath" + :show-reply-button="canReply" @handleDeleteNote="deleteNoteHandler" > <note-edited-text @@ -447,14 +450,7 @@ Please check your network connection and try again.`; > <template v-if="!isReplying && canReply"> <div class="discussion-with-resolve-btn"> - <button - type="button" - class="js-vue-discussion-reply btn btn-text-field qa-discussion-reply" - title="Add a reply" - @click="showReplyForm" - > - Reply... - </button> + <reply-placeholder class="qa-discussion-reply" @onClick="showReplyForm" /> <resolve-discussion-button v-if="discussion.resolvable" :is-resolving="isResolving" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 3c48d81ed05..56108a58010 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -29,6 +29,11 @@ export default { type: Object, required: true, }, + discussion: { + type: Object, + required: false, + default: null, + }, line: { type: Object, required: false, @@ -54,7 +59,7 @@ export default { }; }, computed: { - ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']), + ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']), author() { return this.note.author; }, @@ -80,6 +85,19 @@ export default { isTarget() { return this.targetNoteHash === this.noteAnchorId; }, + discussionId() { + if (this.discussion) { + return this.discussion.id; + } + return ''; + }, + showReplyButton() { + if (!this.discussion || !this.getNoteableData.current_user.can_create_note) { + return false; + } + + return this.discussion.individual_note && !this.commentsDisabled; + }, actionText() { if (!this.commit) { return ''; @@ -231,6 +249,7 @@ export default { :note-id="note.id" :note-url="note.noteable_note_url" :access-level="note.human_access" + :show-reply="showReplyButton" :can-edit="note.current_user.can_edit" :can-award-emoji="note.current_user.can_award_emoji" :can-delete="note.current_user.can_edit" @@ -241,6 +260,7 @@ export default { :is-resolved="note.resolved" :is-resolving="isResolving" :resolved-by="note.resolved_by" + :discussion-id="discussionId" @handleEdit="editHandler" @handleDelete="deleteHandler" @handleResolve="resolveHandler" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index f3fcfdfda05..6d72b72e628 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -44,11 +44,6 @@ export default { required: false, default: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, helpPagePath: { type: String, required: false, @@ -204,7 +199,12 @@ export default { :key="discussion.id" :note="discussion.notes[0]" /> - <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + <noteable-note + v-else + :key="discussion.id" + :note="discussion.notes[0]" + :discussion="discussion" + /> </template> <noteable-discussion v-else @@ -216,10 +216,6 @@ export default { </template> </ul> - <comment-form - v-if="!commentsDisabled" - :noteable-type="noteableType" - :markdown-version="markdownVersion" - /> + <comment-form v-if="!commentsDisabled" :noteable-type="noteableType" /> </div> </template> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 2f715c85fa6..4883266dae5 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -18,7 +18,6 @@ document.addEventListener('DOMContentLoaded', () => { const notesDataset = document.getElementById('js-vue-notes').dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); const noteableData = JSON.parse(notesDataset.noteableData); - const markdownVersion = parseInt(notesDataset.markdownVersion, 10); let currentUserData = {}; noteableData.noteableType = notesDataset.noteableType; @@ -37,7 +36,6 @@ document.addEventListener('DOMContentLoaded', () => { return { noteableData, currentUserData, - markdownVersion, notesData: JSON.parse(notesDataset.notesData), }; }, @@ -47,7 +45,6 @@ document.addEventListener('DOMContentLoaded', () => { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, - markdownVersion: this.markdownVersion, }, }); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 2105a62cecb..ff65f14d529 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -426,5 +426,8 @@ export const submitSuggestion = ( }); }; +export const convertToDiscussion = ({ commit }, noteId) => + commit(types.CONVERT_TO_DISCUSSION, noteId); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index df943c155f4..2bffedad336 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -17,6 +17,7 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; export const APPLY_SUGGESTION = 'APPLY_SUGGESTION'; +export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 33d39ad2ec9..d167f8ef421 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -264,4 +264,9 @@ export default { ).length; state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1; }, + + [types.CONVERT_TO_DISCUSSION](state, discussionId) { + const discussion = utils.findNoteObjectById(state.discussions, discussionId); + Object.assign(discussion, { individual_note: false }); + }, }; diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js index 8f98be79640..01001d4f3ff 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index.js @@ -1,7 +1,5 @@ import ProjectsList from '~/projects_list'; -import Star from '../../../star'; document.addEventListener('DOMContentLoaded', () => { new ProjectsList(); // eslint-disable-line no-new - new Star('.project-row'); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js index 8f98be79640..01001d4f3ff 100644 --- a/app/assets/javascripts/pages/explore/projects/index.js +++ b/app/assets/javascripts/pages/explore/projects/index.js @@ -1,7 +1,5 @@ import ProjectsList from '~/projects_list'; -import Star from '../../../star'; document.addEventListener('DOMContentLoaded', () => { new ProjectsList(); // eslint-disable-line no-new - new Star('.project-row'); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index c7ce4675573..0dd0d5336fc 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import createFlash from '~/flash'; import GfmAutoComplete from '~/gfm_auto_complete'; +import emojiRegex from 'emoji-regex'; import EmojiMenu from './emoji_menu'; const defaultStatusEmoji = 'speech_balloon'; @@ -42,6 +43,17 @@ document.addEventListener('DOMContentLoaded', () => { const emojiAutocomplete = new GfmAutoComplete(); emojiAutocomplete.setup($(statusMessageField), { emojis: true }); + const userNameInput = document.getElementById('user_name'); + userNameInput.addEventListener('input', () => { + const EMOJI_REGEX = emojiRegex(); + if (EMOJI_REGEX.test(userNameInput.value)) { + // set field to invalid so it gets detected by GlFieldErrors + userNameInput.setCustomValidity('Invalid field'); + } else { + userNameInput.setCustomValidity(''); + } + }); + import(/* webpackChunkName: 'emoji' */ '~/emoji') .then(Emoji => { const emojiMenu = new EmojiMenu( diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index 0b644780ad4..0d69a689316 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ -import monitoringBundle from '~/monitoring/monitoring_bundle'; +import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle'; document.addEventListener('DOMContentLoaded', monitoringBundle); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 3ccad513c05..26d7fa7371d 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -43,10 +43,26 @@ document.addEventListener('DOMContentLoaded', () => { ], }); + const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { + if (firstDayOfWeek === 0) { + return weekDays; + } + + return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { + const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; + + return { + ...acc, + [reorderedDayName]: weekDays[reorderedDayName], + }; + }, {}); + }; + const hourData = chartData(projectChartData.hour); responsiveChart($('#hour-chart'), hourData); - const dayData = chartData(projectChartData.weekDays); + const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week); + const dayData = chartData(weekDays); responsiveChart($('#weekday-chart'), dayData); const monthData = chartData(projectChartData.month); diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 8a84ac37dab..afa099d0e0b 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -159,7 +159,7 @@ export default class ActivityCalendar { .append('g') .attr('transform', (group, i) => { _.each(group, (stamp, a) => { - if (a === 0 && stamp.day === 0) { + if (a === 0 && stamp.day === this.firstDayOfWeek) { const month = stamp.date.getMonth(); const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace; const lastMonth = _.last(this.months); @@ -205,6 +205,14 @@ export default class ActivityCalendar { y: 29 + this.dayYPos(5), }, ]; + + if (this.firstDayOfWeek === 1) { + days.push({ + text: 'S', + y: 29 + this.dayYPos(7), + }); + } + this.svg .append('g') .selectAll('text') diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 1c3fd58ca74..39cd891c111 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -234,7 +234,7 @@ export default class UserTabs { data, calendarActivitiesPath, utcOffset, - 0, + gon.first_day_of_week, monthsAgo, ); } diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index cf9db89e32b..2b32a6e4a98 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -108,7 +108,7 @@ export default { :href="status.details_path" :title="tooltipText" :class="cssClassJobName" - class="js-pipeline-graph-job-link" + class="js-pipeline-graph-job-link qa-job-link" > <job-name-component :name="job.name" :status="job.status" /> </gl-link> diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index a33835472bb..5ee510eb11d 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -5,97 +5,101 @@ import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; export default function projectSelect() { - $('.ajax-project-select').each(function(i, select) { - var placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - this.groupId = $(select).data('groupId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('.ajax-project-select').each(function(i, select) { + var placeholder; + const simpleFilter = $(select).data('simpleFilter') || false; + this.groupId = $(select).data('groupId'); + this.includeGroups = $(select).data('includeGroups'); + this.allProjects = $(select).data('allProjects') || false; + this.orderBy = $(select).data('orderBy') || 'id'; + this.withIssuesEnabled = $(select).data('withIssuesEnabled'); + this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); + this.withShared = + $(select).data('withShared') === undefined ? true : $(select).data('withShared'); + this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; + this.allowClear = $(select).data('allowClear') || false; - placeholder = 'Search for project'; - if (this.includeGroups) { - placeholder += ' or group'; - } + placeholder = 'Search for project'; + if (this.includeGroups) { + placeholder += ' or group'; + } - $(select).select2({ - placeholder: placeholder, - minimumInputLength: 0, - query: (function(_this) { - return function(query) { - var finalCallback, projectsCallback; - finalCallback = function(projects) { - var data; - data = { - results: projects, - }; - return query.callback(data); - }; - if (_this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { + $(select).select2({ + placeholder: placeholder, + minimumInputLength: 0, + query: (function(_this) { + return function(query) { + var finalCallback, projectsCallback; + finalCallback = function(projects) { var data; - data = groups.concat(projects); - return finalCallback(data); + data = { + results: projects, + }; + return query.callback(data); }; - return Api.groups(query.term, {}, groupsCallback); + if (_this.includeGroups) { + projectsCallback = function(projects) { + var groupsCallback; + groupsCallback = function(groups) { + var data; + data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (_this.groupId) { + return Api.groupProjects( + _this.groupId, + query.term, + { + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + with_shared: _this.withShared, + include_subgroups: _this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else { + return Api.projects( + query.term, + { + order_by: _this.orderBy, + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + membership: !_this.allProjects, + }, + projectsCallback, + ); + } }; - } else { - projectsCallback = finalCallback; - } - if (_this.groupId) { - return Api.groupProjects( - _this.groupId, - query.term, - { - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - with_shared: _this.withShared, - include_subgroups: _this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } else { - return Api.projects( - query.term, - { - order_by: _this.orderBy, - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - membership: !_this.allProjects, - }, - projectsCallback, - ); - } - }; - })(this), - id: function(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text: function(project) { - return project.name_with_namespace || project.name; - }, + })(this), + id: function(project) { + if (simpleFilter) return project.id; + return JSON.stringify({ + name: project.name, + url: project.web_url, + }); + }, + text: function(project) { + return project.name_with_namespace || project.name; + }, - initSelection: function(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, + initSelection: function(el, callback) { + return Api.project(el.val()).then(({ data }) => callback(data)); + }, - allowClear: this.allowClear, + allowClear: this.allowClear, - dropdownCssClass: 'ajax-project-dropdown', - }); - if (simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); + dropdownCssClass: 'ajax-project-dropdown', + }); + if (simpleFilter) return select; + return new ProjectSelectComboButton(select); + }); + }) + .catch(() => {}); } diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index 3dbac3ff942..d3b5f532dc1 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -44,9 +44,13 @@ export default class ProjectSelectComboButton { // eslint-disable-next-line class-methods-use-this openDropdown(event) { - $(event.currentTarget) - .siblings('.project-item-select') - .select2('open'); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $(event.currentTarget) + .siblings('.project-item-select') + .select2('open'); + }) + .catch(() => {}); } selectProject() { diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 998554d1be5..d65e73a3f9c 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -115,15 +115,35 @@ const bindEvents = () => { const templates = { rails: { text: 'Ruby on Rails', - icon: '.template-option svg.icon-rails', + icon: '.template-option .icon-rails', }, express: { text: 'NodeJS Express', - icon: '.template-option svg.icon-node-express', + icon: '.template-option .icon-express', }, spring: { text: 'Spring', - icon: '.template-option svg.icon-java-spring', + icon: '.template-option .icon-spring', + }, + hugo: { + text: 'Pages/Hugo', + icon: '.template-option .icon-hugo', + }, + jekyll: { + text: 'Pages/Jekyll', + icon: '.template-option .icon-jekyll', + }, + plainhtml: { + text: 'Pages/Plain HTML', + icon: '.template-option .icon-plainhtml', + }, + gitbook: { + text: 'Pages/GitBook', + icon: '.template-option .icon-gitbook', + }, + hexo: { + text: 'Pages/Hexo', + icon: '.template-option .icon-hexo', }, }; diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue new file mode 100644 index 00000000000..4d18c5c4bdd --- /dev/null +++ b/app/assets/javascripts/serverless/components/environment_row.vue @@ -0,0 +1,65 @@ +<script> +import FunctionRow from './function_row.vue'; +import ItemCaret from '~/groups/components/item_caret.vue'; + +export default { + components: { + ItemCaret, + FunctionRow, + }, + props: { + env: { + type: Array, + required: true, + }, + envName: { + type: String, + required: true, + }, + }, + data() { + return { + isOpen: true, + }; + }, + computed: { + envId() { + if (this.envName === '*') { + return 'env-global'; + } + + return `env-${this.envName}`; + }, + isOpenClass() { + return { + 'is-open': this.isOpen, + }; + }, + }, + methods: { + toggleOpen() { + this.isOpen = !this.isOpen; + }, + }, +}; +</script> + +<template> + <li :id="envId" :class="isOpenClass" class="group-row has-children"> + <div + class="group-row-contents d-flex justify-content-end align-items-center" + role="button" + @click.stop="toggleOpen" + > + <div class="folder-toggle-wrap d-flex align-items-center"> + <item-caret :is-group-open="isOpen" /> + </div> + <div class="group-text flex-grow title namespace-title prepend-left-default"> + {{ envName }} + </div> + </div> + <ul v-if="isOpen" class="content-list group-list-tree"> + <function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" /> + </ul> + </li> +</template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index 2b1c21f041b..4f89ad69129 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -1,13 +1,11 @@ <script> import PodBox from './pod_box.vue'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import Icon from '~/vue_shared/components/icon.vue'; +import Url from './url.vue'; export default { components: { - Icon, PodBox, - ClipboardButton, + Url, }, props: { func: { @@ -36,24 +34,9 @@ export default { <section id="serverless-function-details"> <h3>{{ name }}</h3> <div class="append-bottom-default"> - <div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></div> - </div> - <div class="clipboard-group append-bottom-default"> - <div class="label label-monospace">{{ funcUrl }}</div> - <clipboard-button - :text="String(funcUrl)" - :title="s__('ServerlessDetails|Copy URL to clipboard')" - class="input-group-text js-clipboard-btn" - /> - <a - :href="funcUrl" - target="_blank" - rel="noopener noreferrer nofollow" - class="input-group-text btn btn-default" - > - <icon name="external-link" /> - </a> + <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> </div> + <url :uri="funcUrl" /> <h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4> <div v-if="podCount > 0"> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index 44bfae388cb..773d18781fd 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -1,9 +1,12 @@ <script> import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; +import Url from './url.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; export default { components: { Timeago, + Url, }, props: { func: { @@ -16,13 +19,18 @@ export default { return this.func.name; }, description() { - return this.func.description; + const desc = this.func.description.split('\n'); + if (desc.length > 1) { + return desc[1]; + } + + return desc[0]; }, detailUrl() { return this.func.detail_url; }, - environment() { - return this.func.environment_scope; + targetUrl() { + return this.func.url; }, image() { return this.func.image; @@ -31,25 +39,34 @@ export default { return this.func.created_at; }, }, + methods: { + checkClass(element) { + if (element.closest('.no-expand') === null) { + return true; + } + + return false; + }, + openDetails(e) { + if (this.checkClass(e.target)) { + visitUrl(this.detailUrl); + } + }, + }, }; </script> <template> - <div class="gl-responsive-table-row"> - <div class="table-section section-20 section-wrap"> - <a :href="detailUrl">{{ name }}</a> - </div> - <div class="table-section section-10">{{ environment }}</div> - <div class="table-section section-40 section-wrap"> - <span class="line-break">{{ description }}</span> + <li :id="name" class="group-row"> + <div class="group-row-contents" role="button" @click="openDetails"> + <p class="float-right text-right"> + <span>{{ image }}</span + ><br /> + <timeago :time="timestamp" /> + </p> + <b>{{ name }}</b> + <div v-for="line in description.split('\n')" :key="line">{{ line }}</div> + <url :uri="targetUrl" class="prepend-top-8 no-expand" /> </div> - <div class="table-section section-20">{{ image }}</div> - <div class="table-section section-10"><timeago :time="timestamp" /></div> - </div> + </li> </template> - -<style> -.line-break { - white-space: pre; -} -</style> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 9606a78410e..4bde409f906 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,19 +1,21 @@ <script> import { GlSkeletonLoading } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; +import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; export default { components: { + EnvironmentRow, FunctionRow, EmptyState, GlSkeletonLoading, }, props: { functions: { - type: Array, + type: Object, required: true, - default: () => [], + default: () => ({}), }, installed: { type: Boolean, @@ -45,33 +47,21 @@ export default { <section id="serverless-functions"> <div v-if="installed"> <div v-if="hasFunctionData"> - <div class="ci-table js-services-list function-element"> - <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-20" role="rowheader"> - {{ s__('Serverless|Function') }} - </div> - <div class="table-section section-10" role="rowheader"> - {{ s__('Serverless|Cluster Env') }} - </div> - <div class="table-section section-40" role="rowheader"> - {{ s__('Serverless|Description') }} - </div> - <div class="table-section section-20" role="rowheader"> - {{ s__('Serverless|Runtime') }} - </div> - <div class="table-section section-10" role="rowheader"> - {{ s__('Serverless|Last Update') }} - </div> + <template v-if="loadingData"> + <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div> + </template> + <template v-else> + <div class="groups-list-tree-container"> + <ul class="content-list group-list-tree"> + <environment-row + v-for="(env, index) in functions" + :key="index" + :env="env" + :env-name="index" + /> + </ul> </div> - <template v-if="loadingData"> - <div v-for="j in 3" :key="j" class="gl-responsive-table-row"> - <gl-skeleton-loading /> - </div> - </template> - <template v-else> - <function-row v-for="f in functions" :key="f.name" :func="f" /> - </template> - </div> + </template> </div> <div v-else class="empty-state js-empty-state"> <div class="text-content"> @@ -111,16 +101,3 @@ export default { <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" /> </section> </template> - -<style> -.top-area { - border-bottom: 0; -} - -.function-element { - border-bottom: 1px solid #e5e5e5; - border-bottom-color: rgb(229, 229, 229); - border-bottom-style: solid; - border-bottom-width: 1px; -} -</style> diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue new file mode 100644 index 00000000000..ca53bf6c52a --- /dev/null +++ b/app/assets/javascripts/serverless/components/url.vue @@ -0,0 +1,38 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlButton, + ClipboardButton, + }, + props: { + uri: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="clipboard-group"> + <div class="url-text-field label label-monospace">{{ uri }}</div> + <clipboard-button + :text="uri" + :title="s__('ServerlessURL|Copy URL to clipboard')" + class="input-group-text js-clipboard-btn" + /> + <gl-button + :href="uri" + target="_blank" + rel="noopener noreferrer nofollow" + class="input-group-text btn btn-default" + > + <icon name="external-link" /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js index 774c15b5b12..816d55a03f9 100644 --- a/app/assets/javascripts/serverless/stores/serverless_store.js +++ b/app/assets/javascripts/serverless/stores/serverless_store.js @@ -1,7 +1,7 @@ export default class ServerlessStore { constructor(knativeInstalled = false, clustersPath, helpPath) { this.state = { - functions: [], + functions: {}, hasFunctionData: true, loadingData: true, installed: knativeInstalled, @@ -10,8 +10,13 @@ export default class ServerlessStore { }; } - updateFunctionsFromServer(functions = []) { - this.state.functions = functions; + updateFunctionsFromServer(upstreamFunctions = []) { + this.state.functions = upstreamFunctions.reduce((rv, func) => { + const envs = rv; + envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]); + + return envs; + }, {}); } updateLoadingState(loadingData) { diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index edefb3735d7..5172a1ef3d6 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import 'deckar01-task_list'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; @@ -8,46 +9,79 @@ export default class TaskList { this.selector = options.selector; this.dataType = options.dataType; this.fieldName = options.fieldName; + this.lockVersion = options.lockVersion; + this.taskListContainerSelector = `${this.selector} .js-task-list-container`; + this.updateHandler = this.update.bind(this); this.onSuccess = options.onSuccess || (() => {}); - this.onError = function showFlash(e) { - let errorMessages = ''; + this.onError = + options.onError || + function showFlash(e) { + let errorMessages = ''; - if (e.response.data && typeof e.response.data === 'object') { - errorMessages = e.response.data.errors.join(' '); - } + if (e.response.data && typeof e.response.data === 'object') { + errorMessages = e.response.data.errors.join(' '); + } - return new Flash(errorMessages || 'Update failed', 'alert'); - }; + return new Flash(errorMessages || __('Update failed'), 'alert'); + }; this.init(); } init() { - // Prevent duplicate event bindings - this.disable(); - $(`${this.selector} .js-task-list-container`).taskList('enable'); - $(document).on( - 'tasklist:changed', - `${this.selector} .js-task-list-container`, - this.update.bind(this), - ); + this.disable(); // Prevent duplicate event bindings + + $(this.taskListContainerSelector).taskList('enable'); + $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); + } + + getTaskListTarget(e) { + return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector); + } + + disableTaskListItems(e) { + this.getTaskListTarget(e).taskList('disable'); + } + + enableTaskListItems(e) { + this.getTaskListTarget(e).taskList('enable'); } disable() { - $(`${this.selector} .js-task-list-container`).taskList('disable'); - $(document).off('tasklist:changed', `${this.selector} .js-task-list-container`); + this.disableTaskListItems(); + $(document).off('tasklist:changed', this.taskListContainerSelector); } update(e) { const $target = $(e.target); + const { index, checked, lineNumber, lineSource } = e.detail; const patchData = {}; + patchData[this.dataType] = { [this.fieldName]: $target.val(), + lock_version: this.lockVersion, + update_task: { + index, + checked, + line_number: lineNumber, + line_source: lineSource, + }, }; + this.disableTaskListItems(e); + return axios .patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData) - .then(({ data }) => this.onSuccess(data)) - .catch(err => this.onError(err)); + .then(({ data }) => { + this.lockVersion = data.lock_version; + this.enableTaskListItems(e); + + return this.onSuccess(data); + }) + .catch(({ response }) => { + this.enableTaskListItems(e); + + return this.onError(response.data); + }); } } diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index ce051582299..4017630d6ef 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -579,101 +579,109 @@ function UsersSelect(currentUser, els, options = {}) { }; })(this), ); - $('.ajax-users-select').each( - (function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('projectId'); - options.groupId = $(select).data('groupId'); - options.showCurrentUser = $(select).data('currentUser'); - options.authorId = $(select).data('authorId'); - options.skipUsers = $(select).data('skipUsers'); - showNullUser = $(select).data('nullUser'); - showAnyUser = $(select).data('anyUser'); - showEmailUser = $(select).data('emailUser'); - firstUser = $(select).data('firstUser'); - return $(select).select2({ - placeholder: 'Search for a user', - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; - data = { - results: users, - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - ref = data.results; - - for (index = 0, len = ref.length; index < len; index += 1) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('.ajax-users-select').each( + (function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('projectId'); + options.groupId = $(select).data('groupId'); + options.showCurrentUser = $(select).data('currentUser'); + options.authorId = $(select).data('authorId'); + options.skipUsers = $(select).data('skipUsers'); + showNullUser = $(select).data('nullUser'); + showAnyUser = $(select).data('anyUser'); + showEmailUser = $(select).data('emailUser'); + firstUser = $(select).data('firstUser'); + return $(select).select2({ + placeholder: 'Search for a user', + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; + data = { + results: users, + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + ref = data.results; + + for (index = 0, len = ref.length; index < len; index += 1) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } + } + } + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0, + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + name: name, + id: null, + }; + data.results.unshift(anyUser); } } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0, - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; + if ( + showEmailUser && + data.results.length === 0 && + query.term.match(/^[^@]+@[^@]+$/) + ) { + var trimmed = query.term.trim(); + emailUser = { + name: 'Invite "' + trimmed + '" by email', + username: trimmed, + id: trimmed, + invite: true, + }; + data.results.unshift(emailUser); } - anyUser = { - name: name, - id: null, - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: 'Invite "' + trimmed + '" by email', - username: trimmed, - id: trimmed, - invite: true, - }; - data.results.unshift(emailUser); - } - return query.callback(data); + return query.callback(data); + }); + }, + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: 'ajax-users-dropdown', + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + }, }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: 'ajax-users-dropdown', - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; - }, - }); - }; - })(this), - ); + }; + })(this), + ); + }) + .catch(() => {}); } UsersSelect.prototype.initSelection = function(element, callback) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue new file mode 100644 index 00000000000..a38f25cce35 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -0,0 +1,40 @@ +<script> +export default { + props: { + value: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <li> + <div class="commit-message-editor"> + <div class="d-flex flex-wrap align-items-center justify-content-between"> + <label class="col-form-label" :for="inputId"> + <strong>{{ label }}</strong> + </label> + <slot name="header"></slot> + </div> + <textarea + :id="inputId" + :value="value" + class="form-control js-gfm-input append-bottom-default commit-message-edit" + required="required" + rows="7" + @input="$emit('input', $event.target.value)" + ></textarea> + <slot name="checkbox"></slot> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue new file mode 100644 index 00000000000..b3c1c0e329d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -0,0 +1,38 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + commits: { + type: Array, + required: true, + default: () => [], + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown + right + no-caret + text="Use an existing commit message" + variant="link" + class="mr-commit-dropdown" + > + <gl-dropdown-item + v-for="commit in commits" + :key="commit.short_id" + class="text-nowrap text-truncate" + @click="$emit('input', commit.message)" + > + <span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue new file mode 100644 index 00000000000..a1d3a09cca4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import _ from 'underscore'; +import { __, n__, sprintf, s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlButton, + }, + props: { + isSquashEnabled: { + type: Boolean, + required: true, + }, + commitsCount: { + type: Number, + required: false, + default: 0, + }, + targetBranch: { + type: String, + required: true, + }, + }, + data() { + return { + expanded: false, + }; + }, + computed: { + collapseIcon() { + return this.expanded ? 'chevron-down' : 'chevron-right'; + }, + commitsCountMessage() { + return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount); + }, + modifyLinkMessage() { + return this.isSquashEnabled ? __('Modify commit messages') : __('Modify merge commit'); + }, + ariaLabel() { + return this.expanded ? __('Collapse') : __('Expand'); + }, + message() { + return sprintf( + s__( + 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', + ), + { + commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`, + mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`, + targetBranch: `<span class="label-branch">${_.escape(this.targetBranch)}</span>`, + }, + false, + ); + }, + }, + methods: { + toggle() { + this.expanded = !this.expanded; + }, + }, +}; +</script> + +<template> + <div> + <div + class="js-mr-widget-commits-count mr-widget-extension clickable d-flex align-items-center px-3 py-2" + @click="toggle()" + > + <gl-button + :aria-label="ariaLabel" + variant="blank" + class="commit-edit-toggle mr-2" + @click.stop="toggle()" + > + <icon :name="collapseIcon" :size="16" /> + </gl-button> + <span v-if="expanded">{{ __('Collapse') }}</span> + <span v-else> + <span v-html="message"></span> + <gl-button variant="link" class="modify-message-button"> + {{ modifyLinkMessage }} + </gl-button> + </span> + </div> + <div v-show="expanded"><slot></slot></div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index b8f29649eb5..ce4207864ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -2,17 +2,24 @@ import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import simplePoll from '~/lib/utils/simple_poll'; +import { __ } from '~/locale'; import MergeRequest from '../../../merge_request'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; import SquashBeforeMerge from './squash_before_merge.vue'; +import CommitsHeader from './commits_header.vue'; +import CommitEdit from './commit_edit.vue'; +import CommitMessageDropdown from './commit_message_dropdown.vue'; export default { name: 'ReadyToMerge', components: { statusIcon, SquashBeforeMerge, + CommitsHeader, + CommitEdit, + CommitMessageDropdown, }, props: { mr: { type: Object, required: true }, @@ -22,27 +29,20 @@ export default { return { removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, - useCommitMessageWithDescription: false, setToMergeWhenPipelineSucceeds: false, - showCommitMessageEditor: false, isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, squashBeforeMerge: this.mr.squash, successSvg, warningSvg, + squashCommitMessage: this.mr.squashCommitMessage, }; }, computed: { shouldShowMergeWhenPipelineSucceedsText() { return this.mr.isPipelineActive; }, - commitMessageLinkTitle() { - const withDesc = 'Include description in commit message'; - const withoutDesc = "Don't include description in commit message"; - - return this.useCommitMessageWithDescription ? withoutDesc : withDesc; - }, status() { const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; @@ -84,9 +84,9 @@ export default { }, mergeButtonText() { if (this.isMergingImmediately) { - return 'Merge in progress'; + return __('Merge in progress'); } else if (this.shouldShowMergeWhenPipelineSucceedsText) { - return 'Merge when pipeline succeeds'; + return __('Merge when pipeline succeeds'); } return 'Merge'; @@ -98,7 +98,7 @@ export default { const { commitMessage } = this; return Boolean( !commitMessage.length || - !this.shouldShowMergeControls() || + !this.shouldShowMergeControls || this.isMakingRequest || this.mr.preventMerge, ); @@ -110,18 +110,14 @@ export default { const { commitsCount, enableSquashBeforeMerge } = this.mr; return enableSquashBeforeMerge && commitsCount > 1; }, - }, - methods: { shouldShowMergeControls() { return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; }, - updateCommitMessage() { - const cmwd = this.mr.commitMessageWithDescription; - this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; - this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; - }, - toggleCommitMessageEditor() { - this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + methods: { + updateMergeCommitMessage(includeDescription) { + const { commitMessageWithDescription, commitMessage } = this.mr; + this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage; }, handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { // TODO: Remove no-param-reassign @@ -139,6 +135,7 @@ export default { merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, + squash_commit_message: this.squashCommitMessage, }; this.isMakingRequest = true; @@ -158,7 +155,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; - new Flash('Something went wrong. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line }); }, initiateMergePolling() { @@ -194,7 +191,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line }); }, initiateRemoveSourceBranchPolling() { @@ -223,7 +220,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while deleting the source branch. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line }); }, }, @@ -231,127 +228,136 @@ export default { </script> <template> - <div class="mr-widget-body media"> - <status-icon :status="iconClass" /> - <div class="media-body"> - <div class="mr-widget-body-controls media space-children"> - <span class="btn-group"> - <button - :disabled="isMergeButtonDisabled" - :class="mergeButtonClass" - type="button" - class="qa-merge-button" - @click="handleMergeButtonClick()" - > - <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - {{ mergeButtonText }} - </button> - <button - v-if="shouldShowMergeOptionsDropdown" - :disabled="isMergeButtonDisabled" - type="button" - class="btn btn-sm btn-info dropdown-toggle js-merge-moment" - data-toggle="dropdown" - aria-label="Select merge moment" - > - <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> - </button> - <ul - v-if="shouldShowMergeOptionsDropdown" - class="dropdown-menu dropdown-menu-right" - role="menu" - > - <li> - <a - class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option" - href="#" - @click.prevent="handleMergeButtonClick(true)" - > - <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> - <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> - </span> - </a> - </li> - <li> - <a - class="accept-merge-request qa-merge-immediately-option" - href="#" - @click.prevent="handleMergeButtonClick(false, true)" - > - <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> - <span class="media-body merge-opt-title">Merge immediately</span> - </span> - </a> - </li> - </ul> - </span> - <div class="media-body-wrap space-children"> - <template v-if="shouldShowMergeControls()"> - <label v-if="mr.canRemoveSourceBranch"> - <input - id="remove-source-branch-input" - v-model="removeSourceBranch" - :disabled="isRemoveSourceBranchButtonDisabled" - class="js-remove-source-branch-checkbox" - type="checkbox" - /> - Delete source branch - </label> - - <!-- Placeholder for EE extension of this component --> - <squash-before-merge - v-if="shouldShowSquashBeforeMerge" - v-model="squashBeforeMerge" - :help-path="mr.squashBeforeMergeHelpPath" - :is-disabled="isMergeButtonDisabled" - /> - - <span v-if="mr.ffOnlyEnabled" class="js-fast-forward-message"> - Fast-forward merge without a merge commit - </span> + <div> + <div class="mr-widget-body media"> + <status-icon :status="iconClass" /> + <div class="media-body"> + <div class="mr-widget-body-controls media space-children"> + <span class="btn-group"> <button - v-else :disabled="isMergeButtonDisabled" - class="js-modify-commit-message-button btn btn-default btn-sm" + :class="mergeButtonClass" type="button" - @click="toggleCommitMessageEditor" + class="qa-merge-button" + @click="handleMergeButtonClick()" > - Modify commit message + <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i> + {{ mergeButtonText }} </button> - </template> - <template v-else> - <span class="bold js-resolve-mr-widget-items-message"> - You can only merge once the items above are resolved - </span> - </template> - </div> - </div> - <div v-if="showCommitMessageEditor" class="prepend-top-default commit-message-editor"> - <div class="form-group clearfix"> - <label class="col-form-label" for="commit-message"> Commit message </label> - <div class="col-sm-10"> - <div class="commit-message-container"> - <div class="max-width-marker"></div> - <textarea - id="commit-message" - v-model="commitMessage" - class="form-control js-commit-message" - required="required" - rows="14" - name="Commit message" - ></textarea> - </div> - <p class="hint"> - Try to keep the first line under 52 characters and the others under 72 - </p> - <div class="hint"> - <a href="#" @click.prevent="updateCommitMessage"> {{ commitMessageLinkTitle }} </a> - </div> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-sm btn-info dropdown-toggle js-merge-moment" + data-toggle="dropdown" + aria-label="Select merge moment" + > + <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu" + > + <li> + <a + class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option" + href="#" + @click.prevent="handleMergeButtonClick(true)" + > + <span class="media"> + <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> + <span class="media-body merge-opt-title">{{ + __('Merge when pipeline succeeds') + }}</span> + </span> + </a> + </li> + <li> + <a + class="accept-merge-request qa-merge-immediately-option" + href="#" + @click.prevent="handleMergeButtonClick(false, true)" + > + <span class="media"> + <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> + <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span> + </span> + </a> + </li> + </ul> + </span> + <div class="media-body-wrap space-children"> + <template v-if="shouldShowMergeControls"> + <label v-if="mr.canRemoveSourceBranch"> + <input + id="remove-source-branch-input" + v-model="removeSourceBranch" + :disabled="isRemoveSourceBranchButtonDisabled" + class="js-remove-source-branch-checkbox" + type="checkbox" + /> + {{ __('Delete source branch') }} + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + v-model="squashBeforeMerge" + :help-path="mr.squashBeforeMergeHelpPath" + :is-disabled="isMergeButtonDisabled" + /> + </template> + <template v-else> + <span class="bold js-resolve-mr-widget-items-message"> + {{ __('You can only merge once the items above are resolved') }} + </span> + </template> </div> </div> </div> </div> + <template v-if="shouldShowMergeControls"> + <div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message"> + {{ __('Fast-forward merge without a merge commit') }} + </div> + <template v-else> + <commits-header + :is-squash-enabled="squashBeforeMerge" + :commits-count="mr.commitsCount" + :target-branch="mr.targetBranch" + > + <ul class="border-top content-list commits-list flex-list"> + <commit-edit + v-if="squashBeforeMerge" + v-model="squashCommitMessage" + :label="__('Squash commit message')" + input-id="squash-message-edit" + squash + > + <commit-message-dropdown + slot="header" + v-model="squashCommitMessage" + :commits="mr.commits" + /> + </commit-edit> + <commit-edit + v-model="commitMessage" + :label="__('Merge commit message')" + input-id="merge-message-edit" + > + <label slot="checkbox"> + <input + id="include-description" + type="checkbox" + @change="updateMergeCommitMessage($event.target.checked)" + /> + {{ __('Include merge request description') }} + </label> + </commit-edit> + </ul> + </commits-header> + </template> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js deleted file mode 100644 index 8780aa4bd1c..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js +++ /dev/null @@ -1,3 +0,0 @@ -import MRWidgetOptions from './mr_widget_options.vue'; - -export default MRWidgetOptions; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 60cebbfc2b2..0cedbdbdfef 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import MrWidgetOptions from './ee_switch_mr_widget_options'; +import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import Translate from '../vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 5a9d86594b1..abbbe19c5ef 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -3,6 +3,9 @@ import _ from 'underscore'; import { __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; +import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; +import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; +import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import createFlash from '../flash'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; @@ -28,10 +31,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue'; import MergeWhenPipelineSucceedsState from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue'; import CheckingState from './components/states/mr_widget_checking.vue'; -import MRWidgetStore from './stores/ee_switch_mr_widget_store'; -import MRWidgetService from './services/ee_switch_mr_widget_service'; import eventHub from './event_hub'; -import stateMaps from './stores/ee_switch_state_maps'; import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; @@ -142,8 +142,8 @@ export default { } }, methods: { - createService(store) { - const endpoints = { + getServiceEndpoints(store) { + return { mergePath: store.mergePath, mergeCheckPath: store.mergeCheckPath, cancelAutoMergePath: store.cancelAutoMergePath, @@ -154,7 +154,9 @@ export default { mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, }; - return new MRWidgetService(endpoints); + }, + createService(store) { + return new MRWidgetService(this.getServiceEndpoints(store)); }, checkStatus(cb, isRebased) { return this.service @@ -313,7 +315,7 @@ export default { :endpoint="mr.testResultsPath" /> - <div class="mr-widget-section"> + <div class="mr-widget-section p-0"> <component :is="componentName" :mr="mr" :service="service" /> <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> diff --git a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js deleted file mode 100644 index ea2aabb78fe..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js +++ /dev/null @@ -1,3 +0,0 @@ -import MRWidgetService from './mr_widget_service'; - -export default MRWidgetService; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js deleted file mode 100644 index ebef30e3eab..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js +++ /dev/null @@ -1,3 +0,0 @@ -import getStateKey from './get_state_key'; - -export default getStateKey; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js deleted file mode 100644 index 92a07c53f2d..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js +++ /dev/null @@ -1,3 +0,0 @@ -import MergeRequestStore from './mr_widget_store'; - -export default MergeRequestStore; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js deleted file mode 100644 index 50cf9503ea7..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js +++ /dev/null @@ -1,3 +0,0 @@ -import stateMaps from './state_maps'; - -export default stateMaps; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index e5a52c6a7f6..58363f632a9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,5 +1,5 @@ import Timeago from 'timeago.js'; -import getStateKey from './ee_switch_get_state_key'; +import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { stateKey } from './state_maps'; import { formatDate } from '../../lib/utils/datetime_utility'; @@ -32,16 +32,18 @@ export default class MergeRequestStore { this.sourceBranchProtected = data.source_branch_protected; this.conflictsDocsPath = data.conflicts_docs_path; this.mergeStatus = data.merge_status; - this.commitMessage = data.merge_commit_message; + this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merge_commit_sha; this.mergeCommitSha = data.merge_commit_sha; - this.commitMessageWithDescription = data.merge_commit_message_with_description; + this.commitMessageWithDescription = data.default_merge_commit_message_with_description; this.commitsCount = data.commits_count; this.divergedCommitsCount = data.diverged_commits_count; this.pipeline = data.pipeline || {}; this.mergePipeline = data.merge_pipeline || {}; this.deployments = this.deployments || data.deployments || []; this.postMergeDeployments = this.postMergeDeployments || []; + this.commits = data.commits_without_merge_commits || []; + this.squashCommitMessage = data.default_squash_commit_message; this.initRebase(data); if (data.issues_links) { diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index 0b0cd7b75eb..b57455adaad 100644 --- a/app/assets/javascripts/ide/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -1,45 +1,62 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Mousetrap from 'mousetrap'; import VirtualList from 'vue-virtual-scroll-list'; import Item from './item.vue'; -import router from '../../ide_router'; -import { - MAX_FILE_FINDER_RESULTS, - FILE_FINDER_ROW_HEIGHT, - FILE_FINDER_EMPTY_ROW_HEIGHT, -} from '../../constants'; -import { - UP_KEY_CODE, - DOWN_KEY_CODE, - ENTER_KEY_CODE, - ESC_KEY_CODE, -} from '../../../lib/utils/keycodes'; +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; + +export const MAX_FILE_FINDER_RESULTS = 40; +export const FILE_FINDER_ROW_HEIGHT = 55; +export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; + +const originalStopCallback = Mousetrap.stopCallback; export default { components: { Item, VirtualList, }, + props: { + files: { + type: Array, + required: true, + }, + visible: { + type: Boolean, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, + clearSearchOnClose: { + type: Boolean, + required: false, + default: true, + }, + }, data() { return { - focusedIndex: 0, + focusedIndex: -1, searchText: '', mouseOver: false, cancelMouseOver: false, }; }, computed: { - ...mapGetters(['allBlobs']), - ...mapState(['fileFindVisible', 'loading']), filteredBlobs() { const searchText = this.searchText.trim(); if (searchText === '') { - return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS); + return this.files.slice(0, MAX_FILE_FINDER_RESULTS); } - return fuzzaldrinPlus.filter(this.allBlobs, searchText, { + return fuzzaldrinPlus.filter(this.files, searchText, { key: 'path', maxResults: MAX_FILE_FINDER_RESULTS, }); @@ -58,10 +75,12 @@ export default { }, }, watch: { - fileFindVisible() { + visible() { this.$nextTick(() => { - if (!this.fileFindVisible) { - this.searchText = ''; + if (!this.visible) { + if (this.clearSearchOnClose) { + this.searchText = ''; + } } else { this.focusedIndex = 0; @@ -72,7 +91,11 @@ export default { }); }, searchText() { - this.focusedIndex = 0; + this.focusedIndex = -1; + + this.$nextTick(() => { + this.focusedIndex = 0; + }); }, focusedIndex() { if (!this.mouseOver) { @@ -98,8 +121,25 @@ export default { } }, }, + mounted() { + if (this.files.length) { + this.focusedIndex = 0; + } + + Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { + if (e.preventDefault) { + e.preventDefault(); + } + + this.toggle(!this.visible); + }); + + Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); + }, methods: { - ...mapActions(['toggleFileFinder']), + toggle(visible) { + this.$emit('toggle', visible); + }, clearSearchInput() { this.searchText = ''; @@ -139,15 +179,15 @@ export default { this.openFile(this.filteredBlobs[this.focusedIndex]); break; case ESC_KEY_CODE: - this.toggleFileFinder(false); + this.toggle(false); break; default: break; } }, openFile(file) { - this.toggleFileFinder(false); - router.push(`/project${file.url}`); + this.toggle(false); + this.$emit('click', file); }, onMouseOver(index) { if (!this.cancelMouseOver) { @@ -159,14 +199,26 @@ export default { this.cancelMouseOver = false; this.onMouseOver(index); }, + mousetrapStopCallback(e, el, combo) { + if ( + (combo === 't' && el.classList.contains('dropdown-input-field')) || + el.classList.contains('inputarea') + ) { + return true; + } else if (combo === 'command+p' || combo === 'ctrl+p') { + return false; + } + + return originalStopCallback(e, el, combo); + }, }, }; </script> <template> - <div class="ide-file-finder-overlay" @mousedown.self="toggleFileFinder(false)"> - <div class="dropdown-menu diff-file-changes ide-file-finder show"> - <div class="dropdown-input"> + <div class="file-finder-overlay" @mousedown.self="toggle(false)"> + <div class="dropdown-menu diff-file-changes file-finder show"> + <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input"> <input ref="searchInput" v-model="searchText" @@ -186,9 +238,6 @@ export default { ></i> <i :aria-label="__('Clear search input')" - :class="{ - show: showClearInputButton, - }" role="button" class="fa fa-times dropdown-input-clear" @click="clearSearchInput" @@ -203,6 +252,7 @@ export default { :search-text="searchText" :focused="index === focusedIndex" :index="index" + :show-diff-stats="showDiffStats" class="disable-hover" @click="openFile" @mouseover="onMouseOver" @@ -225,3 +275,25 @@ export default { </div> </div> </template> + +<style scoped> +.file-finder-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 200; +} + +.file-finder { + top: 10px; + left: 50%; + transform: translateX(-50%); +} + +.diff-file-changes { + top: 50px; + max-height: 327px; +} +</style> diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue index 83e80d50aff..73511879ff2 100644 --- a/app/assets/javascripts/ide/components/file_finder/item.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -1,5 +1,6 @@ <script> import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '../../../vue_shared/components/file_icon.vue'; import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue'; @@ -7,6 +8,7 @@ const MAX_PATH_LENGTH = 60; export default { components: { + Icon, ChangedFileIcon, FileIcon, }, @@ -27,6 +29,11 @@ export default { type: Number, required: true, }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, }, computed: { pathWithEllipsis() { @@ -97,8 +104,23 @@ export default { </span> </span> </span> - <span v-if="file.changed || file.tempFile" class="diff-changed-stats"> - <changed-file-icon :file="file" /> + <span v-if="file.changed || file.tempFile" v-once class="diff-changed-stats"> + <span v-if="showDiffStats"> + <span class="cgreen bold"> + <icon name="file-addition" class="align-text-top" /> {{ file.addedLines }} + </span> + <span class="cred bold ml-1"> + <icon name="file-deletion" class="align-text-top" /> {{ file.removedLines }} + </span> + </span> + <changed-file-icon v-else :file="file" /> </span> </button> </template> + +<style scoped> +.highlighted { + color: #1f78d1; + font-weight: 600; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index cc07ef46064..3f607aa2a0a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -27,11 +27,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, addSpacingClasses: { type: Boolean, required: false, @@ -158,7 +153,7 @@ export default { this.markdownPreviewLoading = true; this.markdownPreview = __('Loading…'); this.$http - .post(this.versionedPreviewPath(), { text }) + .post(this.markdownPreviewPath, { text }) .then(resp => resp.json()) .then(data => this.renderMarkdown(data)) .catch(() => new Flash(__('Error loading markdown preview'))); @@ -186,13 +181,6 @@ export default { .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => new Flash(__('Error rendering markdown preview'))); }, - - versionedPreviewPath() { - const { markdownPreviewPath, markdownVersion } = this; - return `${markdownPreviewPath}${ - markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' - }markdown_version=${markdownVersion}`; - }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index 8bdb5bf22c2..13eb46437dd 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -40,6 +40,7 @@ export default { toString: date => pikadayToString(date), onSelect: this.selected.bind(this), onClose: this.toggled.bind(this), + firstDay: gon.first_day_of_week, }); this.$el.append(this.calendar.el); diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index d24fe1b547e..f9773622001 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -28,10 +28,10 @@ export default { }, computed: { statusHtml() { - if (this.user.status.emoji && this.user.status.message) { - return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`; - } else if (this.user.status.message) { - return this.user.status.message; + if (this.user.status.emoji && this.user.status.message_html) { + return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; + } else if (this.user.status.message_html) { + return this.user.status.message_html; } return ''; }, diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 0fb9bde1785..c5c3b66438c 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -89,6 +89,10 @@ hr { .str-truncated { @include str-truncated; + &-30 { + @include str-truncated(30%); + } + &-60 { @include str-truncated(60%); } @@ -387,14 +391,26 @@ img.emoji { .flex-no-shrink { flex-shrink: 0; } .ws-initial { white-space: initial; } .overflow-auto { overflow: auto; } +.d-flex-center { + display: flex; + align-items: center; + justify-content: center; +} /** COMMON SIZING CLASSES **/ .w-0 { width: 0; } -.h-13em { height: 13em; } + +.h-12em { height: 12em; } + .mw-460 { max-width: 460px; } .mw-6em { max-width: 6em; } + .min-height-0 { min-height: 0; } +.w-3 { width: #{3 * $grid-size}; } + +.h-3 { width: #{3 * $grid-size}; } + /** COMMON SPACING CLASSES **/ .gl-pl-0 { padding-left: 0; } .gl-pl-1 { padding-left: #{0.5 * $grid-size}; } @@ -420,3 +436,9 @@ img.emoji { .ms-no-clear ::-ms-clear { display: none; } + +/** COMMON POSITIONING CLASSES */ +.position-bottom-0 { bottom: 0; } +.position-left-0 { left: 0; } +.position-right-0 { right: 0; } +.position-top-0 { top: 0; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 037a5adfb7e..6108eaa1ad0 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -4,6 +4,7 @@ */ .file-holder { border: 1px solid $border-color; + border-top: 0; border-radius: $border-radius-default; &.file-holder-no-border { @@ -51,6 +52,7 @@ position: absolute; top: 5px; right: 15px; + margin-left: auto; .btn { padding: 0 10px; @@ -324,10 +326,12 @@ span.idiff { &, .file-holder & { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; background-color: $gray-light; border-bottom: 1px solid $border-color; + border-top: 1px solid $border-color; padding: 5px $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; @@ -365,16 +369,12 @@ span.idiff { margin: 0 10px 0 0; } - .file-actions { - white-space: nowrap; - - .btn { - padding: 0 10px; - font-size: 13px; - line-height: 28px; - display: inline-block; - float: none; - } + .file-actions .btn { + padding: 0 10px; + font-size: 13px; + line-height: 28px; + display: inline-block; + float: none; } @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index a20920e2503..d78c707192f 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -38,7 +38,10 @@ svg { fill: currentColor; +} +.square, +svg { $svg-sizes: 8 10 12 14 16 18 24 32 48 72; @each $svg-size in $svg-sizes { &.s#{$svg-size} { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 9eae9a831fa..96dab609a13 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -243,6 +243,7 @@ $gl-padding-8: 8px; $gl-padding: 16px; $gl-padding-24: 24px; $gl-padding-32: 32px; +$gl-padding-50: 50px; $gl-col-padding: 15px; $gl-input-padding: 10px; $gl-vert-padding: 6px; @@ -490,6 +491,7 @@ $builds-trace-bg: #111; */ $commit-max-width-marker-color: rgba(0, 0, 0, 0); $commit-message-text-area-bg: rgba(0, 0, 0, 0); +$commit-stat-summary-height: 36px; /* * Common @@ -664,8 +666,14 @@ $priority-label-empty-state-width: 114px; Issues Analytics */ $issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15); + /* Merge Requests */ $mr-tabs-height: 51px; $mr-version-controls-height: 56px; + +/* +Compare Branches +*/ +$compare-branches-sticky-header-height: 68px; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 553cc44fe83..2ac98b5d18f 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -395,6 +395,11 @@ $ide-commit-header-height: 48px; svg { vertical-align: sub; } + + .ide-status-avatar { + float: none; + margin: 0 0 1px; + } } .ide-status-file { @@ -811,26 +816,6 @@ $ide-commit-header-height: 48px; z-index: 1; } -.ide-file-finder-overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 100; -} - -.ide-file-finder { - top: 10px; - left: 50%; - transform: translateX(-50%); - - .highlighted { - color: $blue-500; - font-weight: $gl-font-weight-bold; - } -} - .ide-commit-message-field { height: 200px; background-color: $white-light; diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index ad12cd101b6..809ba6d4953 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -58,6 +58,20 @@ } } +.cluster-application-banner { + height: 45px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.cluster-application-banner-close { + align-self: flex-start; + font-weight: 500; + font-size: 20px; + margin: $gl-padding-8 14px 0 0; +} + .cluster-application-description { flex: 1; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 02aac58a475..e3b98b26a11 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -7,22 +7,13 @@ cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { + $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height; + position: -webkit-sticky; position: sticky; - top: $mr-version-controls-height + $header-height + $mr-tabs-height; - margin-left: -1px; - border-left: 1px solid $border-color; + top: $mr-file-header-top; z-index: 102; - &.is-commit { - top: $header-height + 36px; - - .with-performance-bar & { - top: $header-height + 36px + $performance-bar-height; - - } - } - &::before { content: ''; position: absolute; @@ -35,7 +26,23 @@ } .with-performance-bar & { - top: $header-height + $performance-bar-height + $mr-version-controls-height + $mr-tabs-height; + top: $mr-file-header-top + $performance-bar-height; + } + + &.is-commit { + top: $header-height + $commit-stat-summary-height; + + .with-performance-bar & { + top: $header-height + $commit-stat-summary-height + $performance-bar-height; + } + } + + &.is-compare { + top: $header-height + $compare-branches-sticky-header-height; + + .with-performance-bar & { + top: $performance-bar-height + $header-height + $compare-branches-sticky-header-height; + } } } @@ -501,6 +508,25 @@ } } +.diff-stats { + align-items: center; + padding: 0 .25rem; + + .diff-stats-group { + padding: 0 .25rem; + } + + svg.diff-stats-icon { + vertical-align: text-bottom; + } + + &.is-compare-versions-header { + .diff-stats-group { + padding: 0 .5rem; + } + } +} + .file-content .diff-file { margin: 0; border: 0; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index b6abb792709..61ecf133b02 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -240,18 +240,7 @@ } .prometheus-graph { - flex: 1 0 auto; - min-width: 450px; - max-width: 100%; padding: $gl-padding / 2; - - h5 { - font-size: 16px; - } - - @include media-breakpoint-down(sm) { - min-width: 100%; - } } .prometheus-graph-header { @@ -261,6 +250,7 @@ margin-bottom: $gl-padding-8; h5 { + font-size: $gl-font-size-large; margin: 0; } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 53afb182b54..135730d71e9 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -38,9 +38,7 @@ } .mr-widget-section { - .media { - align-items: center; - } + border-radius: $border-radius-default $border-radius-default 0 0; .code-text { flex: 1; @@ -56,6 +54,11 @@ .mr-widget-extension { border-top: 1px solid $border-color; background-color: $gray-light; + + &.clickable:hover { + background-color: $gl-gray-200; + cursor: pointer; + } } .mr-widget-workflow { @@ -78,6 +81,7 @@ border-top: 0; } +.mr-widget-body, .mr-widget-section, .mr-widget-content, .mr-widget-footer { @@ -87,11 +91,38 @@ .mr-state-widget { color: $gl-text-color; + .commit-message-edit { + border-radius: $border-radius-default; + } + .mr-widget-section, .mr-widget-footer { border-top: solid 1px $border-color; } + .mr-fast-forward-message { + padding-left: $gl-padding-50; + padding-bottom: $gl-padding; + } + + .commits-list { + > li { + padding: $gl-padding; + + @include media-breakpoint-up(md) { + padding-left: $gl-padding-50; + } + } + } + + .mr-commit-dropdown { + .dropdown-menu { + @include media-breakpoint-up(md) { + width: 150%; + } + } + } + .mr-widget-footer { padding: 0; } @@ -405,7 +436,7 @@ } .mr-widget-help { - padding: 10px 16px 10px 48px; + padding: 10px 16px 10px $gl-padding-50; font-style: italic; } @@ -423,10 +454,6 @@ } } -.mr-widget-body-controls { - flex-wrap: wrap; -} - .mr_source_commit, .mr_target_commit { margin-bottom: 0; @@ -986,3 +1013,9 @@ width: $ci-action-icon-size-lg; } } + +.merge-request-details .file-finder-overlay.diff-file-finder { + position: fixed; + z-index: 99999; + background: $black-transparent; +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 2342c284a5e..66866aedfba 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -507,12 +507,6 @@ } .template-option { - .logo { - .btn-template-icon { - width: 40px !important; - } - } - padding: 16px 0; &:not(:first-child) { @@ -551,9 +545,8 @@ } .selected-icon { - svg { + img { display: none; - top: 7px; height: 20px; width: 20px; } @@ -946,6 +939,11 @@ pre.light-well { .flex-wrapper { min-width: 0; margin-top: -$gl-padding-8; // negative margin required for flex-wrap + flex: 1 1 100%; + + .project-title { + line-height: 20px; + } } p, @@ -984,14 +982,16 @@ pre.light-well { } .controls { - margin-top: $gl-padding-8; + @include media-breakpoint-down(xs) { + margin-top: $gl-padding-8; + } - @include media-breakpoint-down(md) { + @include media-breakpoint-up(sm) { margin-top: 0; } - @include media-breakpoint-down(xs) { - margin-top: $gl-padding-8; + @include media-breakpoint-up(lg) { + flex: 1 1 40%; } .icon-wrapper { @@ -1041,7 +1041,7 @@ pre.light-well { min-height: 40px; min-width: 40px; - .identicon.s64 { + .identicon.s48 { font-size: 16px; } } diff --git a/app/assets/stylesheets/pages/serverless.scss b/app/assets/stylesheets/pages/serverless.scss new file mode 100644 index 00000000000..a5b73492380 --- /dev/null +++ b/app/assets/stylesheets/pages/serverless.scss @@ -0,0 +1,3 @@ +.url-text-field { + cursor: text; +} diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index c5b9d1f6885..811cc310a8f 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -167,12 +167,14 @@ font-weight: $gl-font-weight-normal; display: inline-block; color: $gl-text-color; + vertical-align: top; } .option-description, .option-disabled-reason { margin-left: 30px; color: $project-option-descr-color; + margin-top: -5px; } .option-disabled-reason { |