diff options
Diffstat (limited to 'app/assets')
147 files changed, 4861 insertions, 1706 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index cbcefb2c18f..8ad3d18b302 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -10,6 +10,9 @@ const Api = { projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', + mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', + mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', + mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', groupLabelsPath: '/groups/:namespace_path/-/labels', licensePath: '/api/:version/templates/licenses/:key', gitignorePath: '/api/:version/templates/gitignores/:key', @@ -22,25 +25,27 @@ const Api = { createBranchPath: '/api/:version/projects/:id/repository/branches', group(groupId, callback) { - const url = Api.buildUrl(Api.groupPath) - .replace(':id', groupId); - return axios.get(url) - .then(({ data }) => { - callback(data); + const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); + return axios.get(url).then(({ data }) => { + callback(data); - return data; - }); + return data; + }); }, // Return groups list. Filtered by query groups(query, options, callback = $.noop) { const url = Api.buildUrl(Api.groupsPath); - return axios.get(url, { - params: Object.assign({ - search: query, - per_page: 20, - }, options), - }) + return axios + .get(url, { + params: Object.assign( + { + search: query, + per_page: 20, + }, + options, + ), + }) .then(({ data }) => { callback(data); @@ -51,12 +56,13 @@ const Api = { // Return namespaces list. Filtered by query namespaces(query, callback) { const url = Api.buildUrl(Api.namespacesPath); - return axios.get(url, { - params: { - search: query, - per_page: 20, - }, - }) + return axios + .get(url, { + params: { + search: query, + per_page: 20, + }, + }) .then(({ data }) => callback(data)); }, @@ -73,9 +79,10 @@ const Api = { defaults.membership = true; } - return axios.get(url, { - params: Object.assign(defaults, options), - }) + return axios + .get(url, { + params: Object.assign(defaults, options), + }) .then(({ data }) => { callback(data); @@ -85,8 +92,32 @@ const Api = { // Return single project project(projectPath) { - const url = Api.buildUrl(Api.projectPath) - .replace(':id', encodeURIComponent(projectPath)); + const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); + + return axios.get(url); + }, + + // Return Merge Request for project + mergeRequest(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); + + return axios.get(url); + }, + + mergeRequestChanges(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestChangesPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); + + return axios.get(url); + }, + + mergeRequestVersions(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestVersionsPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); return axios.get(url); }, @@ -102,30 +133,30 @@ const Api = { url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath); } - return axios.post(url, { - label: data, - }) + return axios + .post(url, { + label: data, + }) .then(res => callback(res.data)) .catch(e => callback(e.response.data)); }, // Return group projects list. Filtered by query groupProjects(groupId, query, callback) { - const url = Api.buildUrl(Api.groupProjectsPath) - .replace(':id', groupId); - return axios.get(url, { - params: { - search: query, - per_page: 20, - }, - }) + const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); + return axios + .get(url, { + params: { + search: query, + per_page: 20, + }, + }) .then(({ data }) => callback(data)); }, commitMultiple(id, data) { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const url = Api.buildUrl(Api.commitPath) - .replace(':id', encodeURIComponent(id)); + const url = Api.buildUrl(Api.commitPath).replace(':id', encodeURIComponent(id)); return axios.post(url, JSON.stringify(data), { headers: { 'Content-Type': 'application/json; charset=utf-8', @@ -136,39 +167,34 @@ const Api = { branchSingle(id, branch) { const url = Api.buildUrl(Api.branchSinglePath) .replace(':id', encodeURIComponent(id)) - .replace(':branch', branch); + .replace(':branch', encodeURIComponent(branch)); return axios.get(url); }, // Return text for a specific license licenseText(key, data, callback) { - const url = Api.buildUrl(Api.licensePath) - .replace(':key', key); - return axios.get(url, { - params: data, - }) + const url = Api.buildUrl(Api.licensePath).replace(':key', key); + return axios + .get(url, { + params: data, + }) .then(res => callback(res.data)); }, gitignoreText(key, callback) { - const url = Api.buildUrl(Api.gitignorePath) - .replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + const url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + return axios.get(url).then(({ data }) => callback(data)); }, gitlabCiYml(key, callback) { - const url = Api.buildUrl(Api.gitlabCiYmlPath) - .replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + const url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + return axios.get(url).then(({ data }) => callback(data)); }, dockerfileYml(key, callback) { const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + return axios.get(url).then(({ data }) => callback(data)); }, issueTemplate(namespacePath, projectPath, key, type, callback) { @@ -177,7 +203,8 @@ const Api = { .replace(':type', type) .replace(':project_path', projectPath) .replace(':namespace_path', namespacePath); - return axios.get(url) + return axios + .get(url) .then(({ data }) => callback(null, data)) .catch(callback); }, @@ -185,10 +212,13 @@ const Api = { users(query, options) { const url = Api.buildUrl(this.usersPath); return axios.get(url, { - params: Object.assign({ - search: query, - per_page: 20, - }, options), + params: Object.assign( + { + search: query, + per_page: 20, + }, + options, + ), }); }, diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 6da33a26e58..976d32abe9b 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -4,7 +4,8 @@ import $ from 'jquery'; import _ from 'underscore'; import Cookies from 'js-cookie'; import { __ } from './locale'; -import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; +import { updateTooltipTitle } from './lib/utils/common_utils'; +import { isInVueNoteablePage } from './lib/utils/dom_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -243,7 +244,7 @@ class AwardsHandler { addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length; - if (this.isInVueNoteablePage() && !isMainAwardsBlock) { + if (isInVueNoteablePage() && !isMainAwardsBlock) { const id = votesBlock.attr('id').replace('note_', ''); this.hideMenuElement($('.emoji-menu')); @@ -295,16 +296,8 @@ class AwardsHandler { } } - isVueMRDiscussions() { - return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); - } - - isInVueNoteablePage() { - return isInIssuePage() || this.isVueMRDiscussions(); - } - getVotesBlock() { - if (this.isInVueNoteablePage()) { + if (isInVueNoteablePage()) { const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); if ($el.length) { diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue new file mode 100644 index 00000000000..6e6cb31e3ac --- /dev/null +++ b/app/assets/javascripts/badges/components/badge.vue @@ -0,0 +1,121 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import Tooltip from '~/vue_shared/directives/tooltip'; + +export default { + name: 'Badge', + components: { + Icon, + LoadingIcon, + Tooltip, + }, + directives: { + Tooltip, + }, + props: { + imageUrl: { + type: String, + required: true, + }, + linkUrl: { + type: String, + required: true, + }, + }, + data() { + return { + hasError: false, + isLoading: true, + numRetries: 0, + }; + }, + computed: { + imageUrlWithRetries() { + if (this.numRetries === 0) { + return this.imageUrl; + } + + return `${this.imageUrl}#retries=${this.numRetries}`; + }, + }, + watch: { + imageUrl() { + this.hasError = false; + this.isLoading = true; + this.numRetries = 0; + }, + }, + methods: { + onError() { + this.isLoading = false; + this.hasError = true; + }, + onLoad() { + this.isLoading = false; + }, + reloadImage() { + this.hasError = false; + this.isLoading = true; + this.numRetries += 1; + }, + }, +}; +</script> + +<template> + <div> + <a + v-show="!isLoading && !hasError" + :href="linkUrl" + target="_blank" + rel="noopener noreferrer" + > + <img + class="project-badge" + :src="imageUrlWithRetries" + @load="onLoad" + @error="onError" + aria-hidden="true" + /> + </a> + + <loading-icon + v-show="isLoading" + :inline="true" + /> + + <div + v-show="hasError" + class="btn-group" + > + <div class="btn btn-default btn-xs disabled"> + <icon + class="prepend-left-8 append-right-8" + name="doc_image" + :size="16" + aria-hidden="true" + /> + </div> + <div + class="btn btn-default btn-xs disabled" + > + <span class="prepend-left-8 append-right-8">{{ s__('Badges|No badge image') }}</span> + </div> + </div> + + <button + v-show="hasError" + class="btn btn-transparent btn-xs text-primary" + type="button" + v-tooltip + :title="s__('Badges|Reload badge image')" + @click="reloadImage" + > + <icon + name="retry" + :size="16" + /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue new file mode 100644 index 00000000000..ae942b2c1a7 --- /dev/null +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -0,0 +1,219 @@ +<script> +import _ from 'underscore'; +import { mapActions, mapState } from 'vuex'; +import createFlash from '~/flash'; +import { s__, sprintf } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import createEmptyBadge from '../empty_badge'; +import Badge from './badge.vue'; + +const badgePreviewDelayInMilliseconds = 1500; + +export default { + name: 'BadgeForm', + components: { + Badge, + LoadingButton, + LoadingIcon, + }, + props: { + isEditing: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapState([ + 'badgeInAddForm', + 'badgeInEditForm', + 'docsUrl', + 'isRendering', + 'isSaving', + 'renderedBadge', + ]), + badge() { + if (this.isEditing) { + return this.badgeInEditForm; + } + + return this.badgeInAddForm; + }, + canSubmit() { + return ( + this.badge !== null && + this.badge.imageUrl && + this.badge.imageUrl.trim() !== '' && + this.badge.linkUrl && + this.badge.linkUrl.trim() !== '' && + !this.isSaving + ); + }, + helpText() { + const placeholders = ['project_path', 'project_id', 'default_branch', 'commit_sha'] + .map(placeholder => `<code>%{${placeholder}}</code>`) + .join(', '); + return sprintf( + s__('Badges|The %{docsLinkStart}variables%{docsLinkEnd} GitLab supports: %{placeholders}'), + { + docsLinkEnd: '</a>', + docsLinkStart: `<a href="${_.escape(this.docsUrl)}">`, + placeholders, + }, + false, + ); + }, + renderedImageUrl() { + return this.renderedBadge ? this.renderedBadge.renderedImageUrl : ''; + }, + renderedLinkUrl() { + return this.renderedBadge ? this.renderedBadge.renderedLinkUrl : ''; + }, + imageUrl: { + get() { + return this.badge ? this.badge.imageUrl : ''; + }, + set(imageUrl) { + const badge = this.badge || createEmptyBadge(); + this.updateBadgeInForm({ + ...badge, + imageUrl, + }); + }, + }, + linkUrl: { + get() { + return this.badge ? this.badge.linkUrl : ''; + }, + set(linkUrl) { + const badge = this.badge || createEmptyBadge(); + this.updateBadgeInForm({ + ...badge, + linkUrl, + }); + }, + }, + submitButtonLabel() { + if (this.isEditing) { + return s__('Badges|Save changes'); + } + return s__('Badges|Add badge'); + }, + }, + methods: { + ...mapActions(['addBadge', 'renderBadge', 'saveBadge', 'stopEditing', 'updateBadgeInForm']), + debouncedPreview: _.debounce(function preview() { + this.renderBadge(); + }, badgePreviewDelayInMilliseconds), + onCancel() { + this.stopEditing(); + }, + onSubmit() { + if (!this.canSubmit) { + return Promise.resolve(); + } + + if (this.isEditing) { + return this.saveBadge() + .then(() => { + createFlash(s__('Badges|The badge was saved.'), 'notice'); + }) + .catch(error => { + createFlash( + s__('Badges|Saving the badge failed, please check the entered URLs and try again.'), + ); + throw error; + }); + } + + return this.addBadge() + .then(() => { + createFlash(s__('Badges|A new badge was added.'), 'notice'); + }) + .catch(error => { + createFlash( + s__('Badges|Adding the badge failed, please check the entered URLs and try again.'), + ); + throw error; + }); + }, + }, + badgeImageUrlPlaceholder: + 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/<badge>.svg', + badgeLinkUrlPlaceholder: 'https://example.gitlab.com/%{project_path}', +}; +</script> + +<template> + <form + class="prepend-top-default append-bottom-default" + @submit.prevent.stop="onSubmit" + > + <div class="form-group"> + <label for="badge-link-url">{{ s__('Badges|Link') }}</label> + <input + id="badge-link-url" + type="text" + class="form-control" + v-model="linkUrl" + :placeholder="$options.badgeLinkUrlPlaceholder" + @input="debouncedPreview" + /> + <span + class="help-block" + v-html="helpText" + ></span> + </div> + + <div class="form-group"> + <label for="badge-image-url">{{ s__('Badges|Badge image URL') }}</label> + <input + id="badge-image-url" + type="text" + class="form-control" + v-model="imageUrl" + :placeholder="$options.badgeImageUrlPlaceholder" + @input="debouncedPreview" + /> + <span + class="help-block" + v-html="helpText" + ></span> + </div> + + <div class="form-group"> + <label for="badge-preview">{{ s__('Badges|Badge image preview') }}</label> + <badge + id="badge-preview" + v-show="renderedBadge && !isRendering" + :image-url="renderedImageUrl" + :link-url="renderedLinkUrl" + /> + <p v-show="isRendering"> + <loading-icon + :inline="true" + /> + </p> + <p + v-show="!renderedBadge && !isRendering" + class="disabled-content" + >{{ s__('Badges|No image to preview') }}</p> + </div> + + <div class="row-content-block"> + <loading-button + type="submit" + container-class="btn btn-success" + :disabled="!canSubmit" + :loading="isSaving" + :label="submitButtonLabel" + /> + <button + class="btn btn-cancel" + type="button" + v-if="isEditing" + @click="onCancel" + >{{ __('Cancel') }}</button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue new file mode 100644 index 00000000000..ca7197e1e0f --- /dev/null +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -0,0 +1,57 @@ +<script> +import { mapState } from 'vuex'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import BadgeListRow from './badge_list_row.vue'; +import { GROUP_BADGE } from '../constants'; + +export default { + name: 'BadgeList', + components: { + BadgeListRow, + LoadingIcon, + }, + computed: { + ...mapState(['badges', 'isLoading', 'kind']), + hasNoBadges() { + return !this.isLoading && (!this.badges || !this.badges.length); + }, + isGroupBadge() { + return this.kind === GROUP_BADGE; + }, + }, +}; +</script> + +<template> + <div class="panel panel-default"> + <div class="panel-heading"> + {{ s__('Badges|Your badges') }} + <span + v-show="!isLoading" + class="badge" + >{{ badges.length }}</span> + </div> + <loading-icon + v-show="isLoading" + class="panel-body" + size="2" + /> + <div + v-if="hasNoBadges" + class="panel-body" + > + <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span> + <span v-else>{{ s__('Badges|This project has no badges') }}</span> + </div> + <div + v-else + class="panel-body" + > + <badge-list-row + v-for="badge in badges" + :key="badge.id" + :badge="badge" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue new file mode 100644 index 00000000000..af062bdf8c6 --- /dev/null +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -0,0 +1,89 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import { PROJECT_BADGE } from '../constants'; +import Badge from './badge.vue'; + +export default { + name: 'BadgeListRow', + components: { + Badge, + Icon, + LoadingIcon, + }, + props: { + badge: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['kind']), + badgeKindText() { + if (this.badge.kind === PROJECT_BADGE) { + return s__('Badges|Project Badge'); + } + + return s__('Badges|Group Badge'); + }, + canEditBadge() { + return this.badge.kind === this.kind; + }, + }, + methods: { + ...mapActions(['editBadge', 'updateBadgeInModal']), + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row-layout gl-responsive-table-row"> + <badge + class="table-section section-30" + :image-url="badge.renderedImageUrl" + :link-url="badge.renderedLinkUrl" + /> + <span class="table-section section-50 str-truncated">{{ badge.linkUrl }}</span> + <div class="table-section section-10"> + <span class="badge">{{ badgeKindText }}</span> + </div> + <div class="table-section section-10 table-button-footer"> + <div + v-if="canEditBadge" + class="table-action-buttons"> + <button + class="btn btn-default append-right-8" + type="button" + :disabled="badge.isDeleting" + @click="editBadge(badge)" + > + <icon + name="pencil" + :size="16" + :aria-label="__('Edit')" + /> + </button> + <button + class="btn btn-danger" + type="button" + data-toggle="modal" + data-target="#delete-badge-modal" + :disabled="badge.isDeleting" + @click="updateBadgeInModal(badge)" + > + <icon + name="remove" + :size="16" + :aria-label="__('Delete')" + /> + </button> + <loading-icon + v-show="badge.isDeleting" + :inline="true" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue new file mode 100644 index 00000000000..83f78394238 --- /dev/null +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -0,0 +1,70 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import Badge from './badge.vue'; +import BadgeForm from './badge_form.vue'; +import BadgeList from './badge_list.vue'; + +export default { + name: 'BadgeSettings', + components: { + Badge, + BadgeForm, + BadgeList, + GlModal, + }, + computed: { + ...mapState(['badgeInModal', 'isEditing']), + deleteModalText() { + return s__( + 'Badges|You are going to delete this badge. Deleted badges <strong>cannot</strong> be restored.', + ); + }, + }, + methods: { + ...mapActions(['deleteBadge']), + onSubmitModal() { + this.deleteBadge(this.badgeInModal) + .then(() => { + createFlash(s__('Badges|The badge was deleted.'), 'notice'); + }) + .catch(error => { + createFlash(s__('Badges|Deleting the badge failed, please try again.')); + throw error; + }); + }, + }, +}; +</script> + +<template> + <div class="badge-settings"> + <gl-modal + id="delete-badge-modal" + :header-title-text="s__('Badges|Delete badge?')" + footer-primary-button-variant="danger" + :footer-primary-button-text="s__('Badges|Delete badge')" + @submit="onSubmitModal"> + <div class="well"> + <badge + :image-url="badgeInModal ? badgeInModal.renderedImageUrl : ''" + :link-url="badgeInModal ? badgeInModal.renderedLinkUrl : ''" + /> + </div> + <p v-html="deleteModalText"></p> + </gl-modal> + + <badge-form + v-show="isEditing" + :is-editing="true" + /> + + <badge-form + v-show="!isEditing" + :is-editing="false" + /> + <badge-list v-show="!isEditing" /> + </div> +</template> diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js new file mode 100644 index 00000000000..8fbe3db5ef1 --- /dev/null +++ b/app/assets/javascripts/badges/constants.js @@ -0,0 +1,2 @@ +export const GROUP_BADGE = 'group'; +export const PROJECT_BADGE = 'project'; diff --git a/app/assets/javascripts/badges/empty_badge.js b/app/assets/javascripts/badges/empty_badge.js new file mode 100644 index 00000000000..49a9b5e1be8 --- /dev/null +++ b/app/assets/javascripts/badges/empty_badge.js @@ -0,0 +1,7 @@ +export default () => ({ + imageUrl: '', + isDeleting: false, + linkUrl: '', + renderedImageUrl: '', + renderedLinkUrl: '', +}); diff --git a/app/assets/javascripts/badges/store/actions.js b/app/assets/javascripts/badges/store/actions.js new file mode 100644 index 00000000000..5542278b3e0 --- /dev/null +++ b/app/assets/javascripts/badges/store/actions.js @@ -0,0 +1,167 @@ +import axios from '~/lib/utils/axios_utils'; +import types from './mutation_types'; + +export const transformBackendBadge = badge => ({ + id: badge.id, + imageUrl: badge.image_url, + kind: badge.kind, + linkUrl: badge.link_url, + renderedImageUrl: badge.rendered_image_url, + renderedLinkUrl: badge.rendered_link_url, + isDeleting: false, +}); + +export default { + requestNewBadge({ commit }) { + commit(types.REQUEST_NEW_BADGE); + }, + receiveNewBadge({ commit }, newBadge) { + commit(types.RECEIVE_NEW_BADGE, newBadge); + }, + receiveNewBadgeError({ commit }) { + commit(types.RECEIVE_NEW_BADGE_ERROR); + }, + addBadge({ dispatch, state }) { + const newBadge = state.badgeInAddForm; + const endpoint = state.apiEndpointUrl; + dispatch('requestNewBadge'); + return axios + .post(endpoint, { + image_url: newBadge.imageUrl, + link_url: newBadge.linkUrl, + }) + .catch(error => { + dispatch('receiveNewBadgeError'); + throw error; + }) + .then(res => { + dispatch('receiveNewBadge', transformBackendBadge(res.data)); + }); + }, + requestDeleteBadge({ commit }, badgeId) { + commit(types.REQUEST_DELETE_BADGE, badgeId); + }, + receiveDeleteBadge({ commit }, badgeId) { + commit(types.RECEIVE_DELETE_BADGE, badgeId); + }, + receiveDeleteBadgeError({ commit }, badgeId) { + commit(types.RECEIVE_DELETE_BADGE_ERROR, badgeId); + }, + deleteBadge({ dispatch, state }, badge) { + const badgeId = badge.id; + dispatch('requestDeleteBadge', badgeId); + const endpoint = `${state.apiEndpointUrl}/${badgeId}`; + return axios + .delete(endpoint) + .catch(error => { + dispatch('receiveDeleteBadgeError', badgeId); + throw error; + }) + .then(() => { + dispatch('receiveDeleteBadge', badgeId); + }); + }, + + editBadge({ commit }, badge) { + commit(types.START_EDITING, badge); + }, + + requestLoadBadges({ commit }, data) { + commit(types.REQUEST_LOAD_BADGES, data); + }, + receiveLoadBadges({ commit }, badges) { + commit(types.RECEIVE_LOAD_BADGES, badges); + }, + receiveLoadBadgesError({ commit }) { + commit(types.RECEIVE_LOAD_BADGES_ERROR); + }, + + loadBadges({ dispatch, state }, data) { + dispatch('requestLoadBadges', data); + const endpoint = state.apiEndpointUrl; + return axios + .get(endpoint) + .catch(error => { + dispatch('receiveLoadBadgesError'); + throw error; + }) + .then(res => { + dispatch('receiveLoadBadges', res.data.map(transformBackendBadge)); + }); + }, + + requestRenderedBadge({ commit }) { + commit(types.REQUEST_RENDERED_BADGE); + }, + receiveRenderedBadge({ commit }, renderedBadge) { + commit(types.RECEIVE_RENDERED_BADGE, renderedBadge); + }, + receiveRenderedBadgeError({ commit }) { + commit(types.RECEIVE_RENDERED_BADGE_ERROR); + }, + + renderBadge({ dispatch, state }) { + const badge = state.isEditing ? state.badgeInEditForm : state.badgeInAddForm; + const { linkUrl, imageUrl } = badge; + if (!linkUrl || linkUrl.trim() === '' || !imageUrl || imageUrl.trim() === '') { + return Promise.resolve(badge); + } + + dispatch('requestRenderedBadge'); + + const parameters = [ + `link_url=${encodeURIComponent(linkUrl)}`, + `image_url=${encodeURIComponent(imageUrl)}`, + ].join('&'); + const renderEndpoint = `${state.apiEndpointUrl}/render?${parameters}`; + return axios + .get(renderEndpoint) + .catch(error => { + dispatch('receiveRenderedBadgeError'); + throw error; + }) + .then(res => { + dispatch('receiveRenderedBadge', transformBackendBadge(res.data)); + }); + }, + + requestUpdatedBadge({ commit }) { + commit(types.REQUEST_UPDATED_BADGE); + }, + receiveUpdatedBadge({ commit }, updatedBadge) { + commit(types.RECEIVE_UPDATED_BADGE, updatedBadge); + }, + receiveUpdatedBadgeError({ commit }) { + commit(types.RECEIVE_UPDATED_BADGE_ERROR); + }, + + saveBadge({ dispatch, state }) { + const badge = state.badgeInEditForm; + const endpoint = `${state.apiEndpointUrl}/${badge.id}`; + dispatch('requestUpdatedBadge'); + return axios + .put(endpoint, { + image_url: badge.imageUrl, + link_url: badge.linkUrl, + }) + .catch(error => { + dispatch('receiveUpdatedBadgeError'); + throw error; + }) + .then(res => { + dispatch('receiveUpdatedBadge', transformBackendBadge(res.data)); + }); + }, + + stopEditing({ commit }) { + commit(types.STOP_EDITING); + }, + + updateBadgeInForm({ commit }, badge) { + commit(types.UPDATE_BADGE_IN_FORM, badge); + }, + + updateBadgeInModal({ commit }, badge) { + commit(types.UPDATE_BADGE_IN_MODAL, badge); + }, +}; diff --git a/app/assets/javascripts/badges/store/index.js b/app/assets/javascripts/badges/store/index.js new file mode 100644 index 00000000000..7a5df403a0e --- /dev/null +++ b/app/assets/javascripts/badges/store/index.js @@ -0,0 +1,13 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: createState(), + actions, + mutations, +}); diff --git a/app/assets/javascripts/badges/store/mutation_types.js b/app/assets/javascripts/badges/store/mutation_types.js new file mode 100644 index 00000000000..d73f91b6005 --- /dev/null +++ b/app/assets/javascripts/badges/store/mutation_types.js @@ -0,0 +1,21 @@ +export default { + RECEIVE_DELETE_BADGE: 'RECEIVE_DELETE_BADGE', + RECEIVE_DELETE_BADGE_ERROR: 'RECEIVE_DELETE_BADGE_ERROR', + RECEIVE_LOAD_BADGES: 'RECEIVE_LOAD_BADGES', + RECEIVE_LOAD_BADGES_ERROR: 'RECEIVE_LOAD_BADGES_ERROR', + RECEIVE_NEW_BADGE: 'RECEIVE_NEW_BADGE', + RECEIVE_NEW_BADGE_ERROR: 'RECEIVE_NEW_BADGE_ERROR', + RECEIVE_RENDERED_BADGE: 'RECEIVE_RENDERED_BADGE', + RECEIVE_RENDERED_BADGE_ERROR: 'RECEIVE_RENDERED_BADGE_ERROR', + RECEIVE_UPDATED_BADGE: 'RECEIVE_UPDATED_BADGE', + RECEIVE_UPDATED_BADGE_ERROR: 'RECEIVE_UPDATED_BADGE_ERROR', + REQUEST_DELETE_BADGE: 'REQUEST_DELETE_BADGE', + REQUEST_LOAD_BADGES: 'REQUEST_LOAD_BADGES', + REQUEST_NEW_BADGE: 'REQUEST_NEW_BADGE', + REQUEST_RENDERED_BADGE: 'REQUEST_RENDERED_BADGE', + REQUEST_UPDATED_BADGE: 'REQUEST_UPDATED_BADGE', + START_EDITING: 'START_EDITING', + STOP_EDITING: 'STOP_EDITING', + UPDATE_BADGE_IN_FORM: 'UPDATE_BADGE_IN_FORM', + UPDATE_BADGE_IN_MODAL: 'UPDATE_BADGE_IN_MODAL', +}; diff --git a/app/assets/javascripts/badges/store/mutations.js b/app/assets/javascripts/badges/store/mutations.js new file mode 100644 index 00000000000..bd84e68c00f --- /dev/null +++ b/app/assets/javascripts/badges/store/mutations.js @@ -0,0 +1,158 @@ +import types from './mutation_types'; +import { PROJECT_BADGE } from '../constants'; + +const reorderBadges = badges => + badges.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === PROJECT_BADGE ? 1 : -1; + } + + return a.id - b.id; + }); + +export default { + [types.RECEIVE_NEW_BADGE](state, newBadge) { + Object.assign(state, { + badgeInAddForm: null, + badges: reorderBadges(state.badges.concat(newBadge)), + isSaving: false, + renderedBadge: null, + }); + }, + [types.RECEIVE_NEW_BADGE_ERROR](state) { + Object.assign(state, { + isSaving: false, + }); + }, + [types.REQUEST_NEW_BADGE](state) { + Object.assign(state, { + isSaving: true, + }); + }, + + [types.RECEIVE_UPDATED_BADGE](state, updatedBadge) { + const badges = state.badges.map(badge => { + if (badge.id === updatedBadge.id) { + return updatedBadge; + } + return badge; + }); + Object.assign(state, { + badgeInEditForm: null, + badges, + isEditing: false, + isSaving: false, + renderedBadge: null, + }); + }, + [types.RECEIVE_UPDATED_BADGE_ERROR](state) { + Object.assign(state, { + isSaving: false, + }); + }, + [types.REQUEST_UPDATED_BADGE](state) { + Object.assign(state, { + isSaving: true, + }); + }, + + [types.RECEIVE_LOAD_BADGES](state, badges) { + Object.assign(state, { + badges: reorderBadges(badges), + isLoading: false, + }); + }, + [types.RECEIVE_LOAD_BADGES_ERROR](state) { + Object.assign(state, { + isLoading: false, + }); + }, + [types.REQUEST_LOAD_BADGES](state, data) { + Object.assign(state, { + kind: data.kind, // project or group + apiEndpointUrl: data.apiEndpointUrl, + docsUrl: data.docsUrl, + isLoading: true, + }); + }, + + [types.RECEIVE_DELETE_BADGE](state, badgeId) { + const badges = state.badges.filter(badge => badge.id !== badgeId); + Object.assign(state, { + badges, + }); + }, + [types.RECEIVE_DELETE_BADGE_ERROR](state, badgeId) { + const badges = state.badges.map(badge => { + if (badge.id === badgeId) { + return { + ...badge, + isDeleting: false, + }; + } + + return badge; + }); + Object.assign(state, { + badges, + }); + }, + [types.REQUEST_DELETE_BADGE](state, badgeId) { + const badges = state.badges.map(badge => { + if (badge.id === badgeId) { + return { + ...badge, + isDeleting: true, + }; + } + + return badge; + }); + Object.assign(state, { + badges, + }); + }, + + [types.RECEIVE_RENDERED_BADGE](state, renderedBadge) { + Object.assign(state, { isRendering: false, renderedBadge }); + }, + [types.RECEIVE_RENDERED_BADGE_ERROR](state) { + Object.assign(state, { isRendering: false }); + }, + [types.REQUEST_RENDERED_BADGE](state) { + Object.assign(state, { isRendering: true }); + }, + + [types.START_EDITING](state, badge) { + Object.assign(state, { + badgeInEditForm: { ...badge }, + isEditing: true, + renderedBadge: { ...badge }, + }); + }, + [types.STOP_EDITING](state) { + Object.assign(state, { + badgeInEditForm: null, + isEditing: false, + renderedBadge: null, + }); + }, + + [types.UPDATE_BADGE_IN_FORM](state, badge) { + if (state.isEditing) { + Object.assign(state, { + badgeInEditForm: badge, + }); + } else { + Object.assign(state, { + badgeInAddForm: badge, + }); + } + }, + + [types.UPDATE_BADGE_IN_MODAL](state, badge) { + Object.assign(state, { + badgeInModal: badge, + }); + }, +}; diff --git a/app/assets/javascripts/badges/store/state.js b/app/assets/javascripts/badges/store/state.js new file mode 100644 index 00000000000..43413aeb5bb --- /dev/null +++ b/app/assets/javascripts/badges/store/state.js @@ -0,0 +1,13 @@ +export default () => ({ + apiEndpointUrl: null, + badgeInAddForm: null, + badgeInEditForm: null, + badgeInModal: null, + badges: [], + docsUrl: null, + renderedBadge: null, + isEditing: false, + isLoading: false, + isRendering: false, + isSaving: false, +}); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 030ca1907e5..ff1cbcad145 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -94,7 +94,7 @@ export default class FileTemplateMediator { const hash = urlPieces[1]; if (hash === 'preview') { this.hideTemplateSelectorMenu(); - } else if (hash === 'editor') { + } else if (hash === 'editor' && !this.typeSelector.isHidden()) { this.showTemplateSelectorMenu(); } }); diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index e52cf249f3a..02228434a29 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -32,6 +32,10 @@ export default class FileTemplateSelector { } } + isHidden() { + return this.$wrapper.hasClass('hidden'); + } + getToggleText() { return this.$dropdownToggleText.text(); } diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 3cffd91716a..bea818010a4 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -5,7 +5,7 @@ import Sortable from 'vendor/Sortable'; import Vue from 'vue'; import AccessorUtilities from '../../lib/utils/accessor'; import boardList from './board_list.vue'; -import boardBlankState from './board_blank_state'; +import BoardBlankState from './board_blank_state.vue'; import './board_delete'; const Store = gl.issueBoards.BoardsStore; @@ -18,7 +18,7 @@ gl.issueBoards.Board = Vue.extend({ components: { boardList, 'board-delete': gl.issueBoards.BoardDelete, - boardBlankState, + BoardBlankState, }, props: { list: Object, diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.vue index 72db626d3c7..2049eeb9c30 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.js +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,42 +1,11 @@ +<script> /* global ListLabel */ - import _ from 'underscore'; import Cookies from 'js-cookie'; const Store = gl.issueBoards.BoardsStore; export default { - template: ` - <div class="board-blank-state"> - <p> - Add the following default lists to your Issue Board with one click: - </p> - <ul class="board-blank-state-list"> - <li v-for="label in predefinedLabels"> - <span - class="label-color" - :style="{ backgroundColor: label.color }"> - </span> - {{ label.title }} - </li> - </ul> - <p> - Starting out with the default set of lists will get you right on the way to making the most of your board. - </p> - <button - class="btn btn-create btn-inverted btn-block" - type="button" - @click.stop="addDefaultLists"> - Add default lists - </button> - <button - class="btn btn-default btn-block" - type="button" - @click.stop="clearBlankState"> - Nevermind, I'll use my own - </button> - </div> - `, data() { return { predefinedLabels: [ @@ -89,3 +58,41 @@ export default { clearBlankState: Store.removeBlankState.bind(Store), }, }; + +</script> + +<template> + <div class="board-blank-state"> + <p> + Add the following default lists to your Issue Board with one click: + </p> + <ul class="board-blank-state-list"> + <li + v-for="(label, index) in predefinedLabels" + :key="index" + > + <span + class="label-color" + :style="{ backgroundColor: label.color }"> + </span> + {{ label.title }} + </li> + </ul> + <p> + Starting out with the default set of lists will get you + right on the way to making the most of your board. + </p> + <button + class="btn btn-create btn-inverted btn-block" + type="button" + @click.stop="addDefaultLists"> + Add default lists + </button> + <button + class="btn btn-default btn-block" + type="button" + @click.stop="clearBlankState"> + Nevermind, I'll use my own + </button> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index a44969272a1..c4ee4f6c855 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -60,10 +60,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({ this.issue = this.detail.issue; this.list = this.detail.list; - - this.$nextTick(() => { - this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate; - }); }, deep: true }, @@ -91,7 +87,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ saveAssignees () { this.loadingAssignees = true; - gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint) + gl.issueBoards.BoardsStore.detail.issue.update() .then(() => { this.loadingAssignees = false; }) diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index 8aee5b23c76..84fe9b1288a 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -68,15 +68,6 @@ gl.issueBoards.IssueCardInner = Vue.extend({ return this.issue.assignees.length > this.numberOverLimit; }, - cardUrl() { - let baseUrl = this.issueLinkBase; - - if (this.groupId && this.issue.project) { - baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path); - } - - return `${baseUrl}/${this.issue.iid}`; - }, issueId() { if (this.issue.iid) { return `#${this.issue.iid}`; @@ -153,13 +144,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({ /> <a class="js-no-trigger" - :href="cardUrl" + :href="issue.path" :title="issue.title">{{ issue.title }}</a> <span class="card-number" v-if="issueId" > - <template v-if="groupId && issue.project">{{issue.project.path}}</template>{{ issueId }} + {{ issue.referencePath }} </span> </h4> <div class="card-assignee"> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js index e571b11a83d..9e37f95cdd6 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.js +++ b/app/assets/javascripts/boards/components/modal/empty_state.js @@ -1,9 +1,9 @@ import Vue from 'vue'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; +import modalMixin from '../../mixins/modal_mixins'; gl.issueBoards.ModalEmptyState = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], data() { return ModalStore.store; }, diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index 03cd7ef65cb..9735e0ddacc 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -3,11 +3,11 @@ import Flash from '../../../flash'; import { __ } from '../../../locale'; import './lists_dropdown'; import { pluralize } from '../../../lib/utils/text_utility'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; +import modalMixin from '../../mixins/modal_mixins'; gl.issueBoards.ModalFooter = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], data() { return { modal: ModalStore.store, diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js index 31f59d295bf..67c29ebca72 100644 --- a/app/assets/javascripts/boards/components/modal/header.js +++ b/app/assets/javascripts/boards/components/modal/header.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import modalFilters from './filters'; import './tabs'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; +import modalMixin from '../../mixins/modal_mixins'; gl.issueBoards.ModalHeader = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], props: { projectId: { type: Number, diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index d825ff38587..3083b3e4405 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -7,8 +7,7 @@ import './header'; import './list'; import './footer'; import './empty_state'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; gl.issueBoards.IssuesModal = Vue.extend({ props: { diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js index 7c62134b3a3..6b04a6c7a6c 100644 --- a/app/assets/javascripts/boards/components/modal/list.js +++ b/app/assets/javascripts/boards/components/modal/list.js @@ -2,8 +2,7 @@ import Vue from 'vue'; import bp from '../../../breakpoints'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; gl.issueBoards.ModalList = Vue.extend({ props: { diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js index 4684ea76647..e644de2d4fc 100644 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js @@ -1,6 +1,5 @@ import Vue from 'vue'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ data() { diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js index 3e5d08e3d75..b6465a88e5e 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.js +++ b/app/assets/javascripts/boards/components/modal/tabs.js @@ -1,9 +1,9 @@ import Vue from 'vue'; - -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../../stores/modal_store'; +import modalMixin from '../../mixins/modal_mixins'; gl.issueBoards.ModalTabs = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], data() { return ModalStore.store; }, diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js index 09c683ff621..0a0820ec5fd 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js @@ -17,14 +17,10 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ type: Object, required: true, }, - issueUpdate: { - type: String, - required: true, - }, }, computed: { updateUrl() { - return this.issueUpdate.replace(':project_path', this.issue.project.path); + return this.issue.path; }, }, methods: { diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index fb40b9f5565..70367c4f711 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -6,6 +6,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { constructor(store, updateUrl = false, cantEdit = []) { super({ page: 'boards', + isGroupDecendent: true, stateFiltersSelector: '.issues-state-filters', }); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 8b1c14c04ff..a6f8681cfac 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -17,9 +17,9 @@ import './models/milestone'; import './models/project'; import './models/assignee'; import './stores/boards_store'; -import './stores/modal_store'; +import ModalStore from './stores/modal_store'; import BoardService from './services/board_service'; -import './mixins/modal_mixins'; +import modalMixin from './mixins/modal_mixins'; import './mixins/sortable_default_options'; import './filters/due_date_filters'; import './components/board'; @@ -31,7 +31,6 @@ import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/fi export default () => { const $boardApp = document.getElementById('board-app'); const Store = gl.issueBoards.BoardsStore; - const ModalStore = gl.issueBoards.ModalStore; window.gl = window.gl || {}; @@ -176,7 +175,7 @@ export default () => { gl.IssueBoardsModalAddBtn = new Vue({ el: document.getElementById('js-add-issues-btn'), - mixins: [gl.issueBoards.ModalMixins], + mixins: [modalMixin], data() { return { modal: ModalStore.store, diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js index 2b0a1aaa89f..6c97e1629bf 100644 --- a/app/assets/javascripts/boards/mixins/modal_mixins.js +++ b/app/assets/javascripts/boards/mixins/modal_mixins.js @@ -1,6 +1,6 @@ -const ModalStore = gl.issueBoards.ModalStore; +import ModalStore from '../stores/modal_store'; -gl.issueBoards.ModalMixins = { +export default { methods: { toggleModal(toggle) { ModalStore.store.showAddIssuesModal = toggle; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 4c5079efc8b..b381d48d625 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -23,6 +23,8 @@ class ListIssue { }; this.isLoading = {}; this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; + this.referencePath = obj.reference_path; + this.path = obj.real_path; this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; this.milestone_id = obj.milestone_id; this.project_id = obj.project_id; @@ -98,7 +100,7 @@ class ListIssue { this.isLoading[key] = value; } - update (url) { + update () { const data = { issue: { milestone_id: this.milestone ? this.milestone.id : null, @@ -113,7 +115,7 @@ class ListIssue { } const projectPath = this.project ? this.project.path : ''; - return Vue.http.patch(url.replace(':project_path', projectPath), data); + return Vue.http.patch(`${this.path}.json`, data); } } diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index d78d4701974..7c90597f77c 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -19,7 +19,7 @@ export default class BoardService { } static generateIssuePath(boardId, id) { - return `${gon.relative_url_root}/-/boards/${boardId ? `/${boardId}` : ''}/issues${id ? `/${id}` : ''}`; + return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${id ? `/${id}` : ''}`; } all() { diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js index 4fdc925c825..a4220cd840d 100644 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ b/app/assets/javascripts/boards/stores/modal_store.js @@ -1,6 +1,3 @@ -window.gl = window.gl || {}; -window.gl.issueBoards = window.gl.issueBoards || {}; - class ModalStore { constructor() { this.store = { @@ -95,4 +92,4 @@ class ModalStore { } } -gl.issueBoards.ModalStore = new ModalStore(); +export default new ModalStore(); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 466a5b5d635..24d63b99a29 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -55,22 +55,20 @@ }, methods: { successCallback(resp) { - return resp.json().then((response) => { - // depending of the endpoint the response can either bring a `pipelines` key or not. - const pipelines = response.pipelines || response; - this.setCommonData(pipelines); + // depending of the endpoint the response can either bring a `pipelines` key or not. + const pipelines = resp.data.pipelines || resp.data; + this.setCommonData(pipelines); - const updatePipelinesEvent = new CustomEvent('update-pipelines-count', { - detail: { - pipelines: response, - }, - }); - - // notifiy to update the count in tabs - if (this.$el.parentElement) { - this.$el.parentElement.dispatchEvent(updatePipelinesEvent); - } + const updatePipelinesEvent = new CustomEvent('update-pipelines-count', { + detail: { + pipelines: resp.data, + }, }); + + // notifiy to update the count in tabs + if (this.$el.parentElement) { + this.$el.parentElement.dispatchEvent(updatePipelinesEvent); + } }, }, }; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index e6390f0855b..d7e1de18d09 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -26,8 +26,8 @@ export default class FilteredSearchDropdownManager { this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; this.groupsOnly = isGroup; - this.groupAncestor = isGroupAncestor; - this.isGroupDecendent = isGroupDecendent; + this.includeAncestorGroups = isGroupAncestor; + this.includeDescendantGroups = isGroupDecendent; this.setupMapping(); @@ -108,7 +108,19 @@ export default class FilteredSearchDropdownManager { } getLabelsEndpoint() { - const endpoint = `${this.baseEndpoint}/labels.json`; + let endpoint = `${this.baseEndpoint}/labels.json?`; + + if (this.groupsOnly) { + endpoint = `${endpoint}only_group_labels=true&`; + } + + if (this.includeAncestorGroups) { + endpoint = `${endpoint}include_ancestor_groups=true&`; + } + + if (this.includeDescendantGroups) { + endpoint = `${endpoint}include_descendant_groups=true`; + } return endpoint; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 71b7e80335b..cf5ba1e1771 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -21,7 +21,7 @@ export default class FilteredSearchManager { constructor({ page, isGroup = false, - isGroupAncestor = false, + isGroupAncestor = true, isGroupDecendent = false, filteredSearchTokenKeys = FilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', @@ -86,6 +86,7 @@ export default class FilteredSearchManager { page: this.page, isGroup: this.isGroup, isGroupAncestor: this.isGroupAncestor, + isGroupDecendent: this.isGroupDecendent, filteredSearchTokenKeys: this.filteredSearchTokenKeys, }); diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue index 0c54c992e51..037e3efb4ce 100644 --- a/app/assets/javascripts/ide/components/changed_file_icon.vue +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -1,25 +1,25 @@ <script> - import icon from '~/vue_shared/components/icon.vue'; +import icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - icon, +export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, }, - props: { - file: { - type: Object, - required: true, - }, + }, + computed: { + changedIcon() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; }, - computed: { - changedIcon() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; - }, - changedIconClass() { - return `multi-${this.changedIcon}`; - }, + changedIconClass() { + return `multi-${this.changedIcon}`; }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 18934af004a..560cdd941cd 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -1,38 +1,36 @@ <script> - import { mapActions } from 'vuex'; - import icon from '~/vue_shared/components/icon.vue'; - import router from '../../ide_router'; +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - icon, +export default { + components: { + Icon, + }, + props: { + file: { + type: Object, + required: true, }, - props: { - file: { - type: Object, - required: true, - }, + }, + computed: { + iconName() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; }, - computed: { - iconName() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; - }, - iconClass() { - return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; - }, + iconClass() { + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; }, - methods: { - ...mapActions([ - 'discardFileChanges', - 'updateViewer', - ]), - openFileInEditor(file) { - this.updateViewer('diff'); - - router.push(`/project${file.url}`); - }, + }, + methods: { + ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']), + openFileInEditor(file) { + return this.openPendingTab(file).then(changeViewer => { + if (changeViewer) { + this.updateViewer('diff'); + } + }); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 170347881e0..0c44a755f56 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,31 +1,44 @@ <script> - import Icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __, sprintf } from '~/locale'; - export default { - components: { - Icon, +export default { + components: { + Icon, + }, + props: { + hasChanges: { + type: Boolean, + required: false, + default: false, }, - props: { - hasChanges: { - type: Boolean, - required: false, - default: false, - }, - viewer: { - type: String, - required: true, - }, - showShadow: { - type: Boolean, - required: true, - }, + mergeRequestId: { + type: String, + required: false, + default: '', }, - methods: { - changeMode(mode) { - this.$emit('click', mode); - }, + viewer: { + type: String, + required: true, }, - }; + showShadow: { + type: Boolean, + required: true, + }, + }, + computed: { + mergeReviewLine() { + return sprintf(__('Reviewing (merge request !%{mergeRequestId})'), { + mergeRequestId: this.mergeRequestId, + }); + }, + }, + methods: { + changeMode(mode) { + this.$emit('click', mode); + }, + }, +}; </script> <template> @@ -43,7 +56,10 @@ }" data-toggle="dropdown" > - <template v-if="viewer === 'editor'"> + <template v-if="viewer === 'mrdiff' && mergeRequestId"> + {{ mergeReviewLine }} + </template> + <template v-else-if="viewer === 'editor'"> {{ __('Editing') }} </template> <template v-else> @@ -57,6 +73,29 @@ </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> <ul> + <template v-if="mergeRequestId"> + <li> + <a + href="#" + @click.prevent="changeMode('mrdiff')" + :class="{ + 'is-active': viewer === 'mrdiff', + }" + > + <strong class="dropdown-menu-inner-title"> + {{ mergeReviewLine }} + </strong> + <span class="dropdown-menu-inner-content"> + {{ __('Compare changes with the merge request target branch') }} + </span> + </a> + </li> + <li + role="separator" + class="divider" + > + </li> + </template> <li> <a href="#" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 015e750525a..1c237c0ec97 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,51 +1,49 @@ <script> - import { mapState, mapGetters } from 'vuex'; - import ideSidebar from './ide_side_bar.vue'; - import ideContextbar from './ide_context_bar.vue'; - import repoTabs from './repo_tabs.vue'; - import repoFileButtons from './repo_file_buttons.vue'; - import ideStatusBar from './ide_status_bar.vue'; - import repoEditor from './repo_editor.vue'; +import { mapState, mapGetters } from 'vuex'; +import ideSidebar from './ide_side_bar.vue'; +import ideContextbar from './ide_context_bar.vue'; +import repoTabs from './repo_tabs.vue'; +import ideStatusBar from './ide_status_bar.vue'; +import repoEditor from './repo_editor.vue'; - export default { - components: { - ideSidebar, - ideContextbar, - repoTabs, - repoFileButtons, - ideStatusBar, - repoEditor, +export default { + components: { + ideSidebar, + ideContextbar, + repoTabs, + ideStatusBar, + repoEditor, + }, + props: { + emptyStateSvgPath: { + type: String, + required: true, }, - props: { - emptyStateSvgPath: { - type: String, - required: true, - }, - noChangesStateSvgPath: { - type: String, - required: true, - }, - committedStateSvgPath: { - type: String, - required: true, - }, + noChangesStateSvgPath: { + type: String, + required: true, }, - computed: { - ...mapState(['changedFiles', 'openFiles', 'viewer']), - ...mapGetters(['activeFile', 'hasChanges']), + committedStateSvgPath: { + type: String, + required: true, }, - mounted() { - const returnValue = 'Are you sure you want to lose unsaved changes?'; - window.onbeforeunload = e => { - if (!this.changedFiles.length) return undefined; + }, + computed: { + ...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']), + ...mapGetters(['activeFile', 'hasChanges']), + }, + mounted() { + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = e => { + if (!this.changedFiles.length) return undefined; - Object.assign(e, { - returnValue, - }); - return returnValue; - }; - }, - }; + Object.assign(e, { + returnValue, + }); + return returnValue; + }; + }, +}; </script> <template> @@ -60,17 +58,16 @@ v-if="activeFile" > <repo-tabs + :active-file="activeFile" :files="openFiles" :viewer="viewer" :has-changes="hasChanges" + :merge-request-id="currentMergeRequestId" /> <repo-editor class="multi-file-edit-pane-content" :file="activeFile" /> - <repo-file-buttons - :file="activeFile" - /> <ide-status-bar :file="activeFile" /> diff --git a/app/assets/javascripts/ide/components/ide_file_buttons.vue b/app/assets/javascripts/ide/components/ide_file_buttons.vue new file mode 100644 index 00000000000..a6c6f46a144 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_file_buttons.vue @@ -0,0 +1,84 @@ +<script> +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + showButtons() { + return ( + this.file.rawPath || this.file.blamePath || this.file.commitsPath || this.file.permalink + ); + }, + rawDownloadButtonLabel() { + return this.file.binary ? __('Download') : __('Raw'); + }, + }, +}; +</script> + +<template> + <div + v-if="showButtons" + class="pull-right ide-btn-group" + > + <a + v-tooltip + v-if="!file.binary" + :href="file.blamePath" + :title="__('Blame')" + class="btn btn-xs btn-transparent blame" + > + <icon + name="blame" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.commitsPath" + :title="__('History')" + class="btn btn-xs btn-transparent history" + > + <icon + name="history" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.permalink" + :title="__('Permalink')" + class="btn btn-xs btn-transparent permalink" + > + <icon + name="link" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.rawPath" + target="_blank" + class="btn btn-xs btn-transparent prepend-left-10 raw" + rel="noopener noreferrer" + :title="rawDownloadButtonLabel"> + <icon + name="download" + :size="16" + /> + </a> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 9c386896448..152a5f632ad 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,25 +1,23 @@ <script> - import icon from '~/vue_shared/components/icon.vue'; - import tooltip from '~/vue_shared/directives/tooltip'; - import timeAgoMixin from '~/vue_shared/mixins/timeago'; +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import timeAgoMixin from '~/vue_shared/mixins/timeago'; - export default { - components: { - icon, +export default { + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [timeAgoMixin], + props: { + file: { + type: Object, + required: true, }, - directives: { - tooltip, - }, - mixins: [ - timeAgoMixin, - ], - props: { - file: { - type: Object, - required: true, - }, - }, - }; + }, +}; </script> <template> @@ -50,7 +48,9 @@ <div class="text-right"> {{ file.eol }} </div> - <div class="text-right"> + <div + class="text-right" + v-if="!file.binary"> {{ file.editorRow }}:{{ file.editorColumn }} </div> <div class="text-right"> diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue new file mode 100644 index 00000000000..8a440902dfc --- /dev/null +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -0,0 +1,23 @@ +<script> +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + icon, + }, + directives: { + tooltip, + }, +}; +</script> + +<template> + <icon + name="git-merge" + v-tooltip + title="__('Part of merge request changes')" + css-classes="ide-file-changed-icon" + :size="12" + /> +</template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index e73d1ce839f..6aa44ca2c11 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,11 +1,17 @@ <script> /* global monaco */ -import { mapState, mapActions } from 'vuex'; +import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '~/flash'; +import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; +import IdeFileButtons from './ide_file_buttons.vue'; export default { + components: { + ContentViewer, + IdeFileButtons, + }, props: { file: { type: Object, @@ -13,31 +19,40 @@ export default { }, }, computed: { - ...mapState([ - 'leftPanelCollapsed', - 'rightPanelCollapsed', - 'viewer', - 'delayViewerUpdated', - ]), + ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), + ...mapGetters(['currentMergeRequest']), shouldHideEditor() { return this.file && this.file.binary && !this.file.raw; }, + editTabCSS() { + return { + active: this.file.viewMode === 'edit', + }; + }, + previewTabCSS() { + return { + active: this.file.viewMode === 'preview', + }; + }, }, watch: { file(oldVal, newVal) { - if (newVal.path !== this.file.path) { + // Compare key to allow for files opened in review mode to be cached differently + if (newVal.key !== this.file.key) { this.initMonaco(); } }, - leftPanelCollapsed() { - this.editor.updateDimensions(); - }, rightPanelCollapsed() { this.editor.updateDimensions(); }, viewer() { this.createEditorInstance(); }, + panelResizing() { + if (!this.panelResizing) { + this.editor.updateDimensions(); + } + }, }, beforeDestroy() { this.editor.dispose(); @@ -59,6 +74,7 @@ export default { 'changeFileContent', 'setFileLanguage', 'setEditorPosition', + 'setFileViewMode', 'setFileEOL', 'updateViewer', 'updateDelayViewerUpdated', @@ -68,9 +84,14 @@ export default { this.editor.clearEditor(); - this.getRawFileData(this.file) + this.getRawFileData({ + path: this.file.path, + baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '', + }) .then(() => { - const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve(); + const viewerPromise = this.delayViewerUpdated + ? this.updateViewer(this.file.pending ? 'diff' : 'editor') + : Promise.resolve(); return viewerPromise; }) @@ -78,7 +99,7 @@ export default { this.updateDelayViewerUpdated(false); this.createEditorInstance(); }) - .catch((err) => { + .catch(err => { flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true); throw err; }); @@ -101,9 +122,13 @@ export default { this.model = this.editor.createModel(this.file); - this.editor.attachModel(this.model); + if (this.viewer === 'mrdiff') { + this.editor.attachMergeRequestModel(this.model); + } else { + this.editor.attachModel(this.model); + } - this.model.onChange((model) => { + this.model.onChange(model => { const { file } = model; if (file.active) { @@ -146,16 +171,49 @@ export default { id="ide" class="blob-viewer-container blob-editor-container" > - <div - v-if="shouldHideEditor" - v-html="file.html" - > + <div class="ide-mode-tabs clearfix"> + <ul + class="nav-links pull-left" + v-if="!shouldHideEditor"> + <li :class="editTabCSS"> + <a + href="javascript:void(0);" + role="button" + @click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> + <template v-if="viewer === 'editor'"> + {{ __('Edit') }} + </template> + <template v-else> + {{ __('Review') }} + </template> + </a> + </li> + <li + v-if="file.previewMode" + :class="previewTabCSS"> + <a + href="javascript:void(0);" + role="button" + @click.prevent="setFileViewMode({ file, viewMode:'preview' })"> + {{ file.previewMode.previewTitle }} + </a> + </li> + </ul> + <ide-file-buttons + :file="file" + /> </div> <div - v-show="!shouldHideEditor" + v-show="!shouldHideEditor && file.viewMode === 'edit'" ref="editor" class="multi-file-editor-holder" > </div> + <content-viewer + v-if="shouldHideEditor || file.viewMode === 'preview'" + :content="file.content || file.raw" + :path="file.rawPath" + :file-size="file.size" + :project-path="file.projectId"/> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index 297b9c2628f..3b5068d4910 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -6,6 +6,7 @@ import router from '../ide_router'; import newDropdown from './new_dropdown/index.vue'; import fileStatusIcon from './repo_file_status_icon.vue'; import changedFileIcon from './changed_file_icon.vue'; +import mrFileIcon from './mr_file_icon.vue'; export default { name: 'RepoFile', @@ -15,6 +16,7 @@ export default { fileStatusIcon, fileIcon, changedFileIcon, + mrFileIcon, }, props: { file: { @@ -56,18 +58,11 @@ export default { ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']), clickFile() { // Manual Action if a tree is selected/opened - if ( - this.isTree && - this.$router.currentRoute.path === `/project${this.file.url}` - ) { + if (this.isTree && this.$router.currentRoute.path === `/project${this.file.url}`) { this.toggleTreeOpen(this.file.path); } - const delayPromise = this.file.changed - ? Promise.resolve() - : this.updateDelayViewerUpdated(true); - - return delayPromise.then(() => { + return this.updateDelayViewerUpdated(true).then(() => { router.push(`/project${this.file.url}`); }); }, @@ -102,11 +97,15 @@ export default { :file="file" /> </span> - <changed-file-icon - :file="file" - v-if="file.changed || file.tempFile" - class="prepend-top-5 pull-right" - /> + <span class="pull-right"> + <mr-file-icon + v-if="file.mrChange" + /> + <changed-file-icon + :file="file" + v-if="file.changed || file.tempFile" + /> + </span> <new-dropdown v-if="isTree" :project-id="file.projectId" diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue deleted file mode 100644 index 4ea8cf7504b..00000000000 --- a/app/assets/javascripts/ide/components/repo_file_buttons.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -export default { - props: { - file: { - type: Object, - required: true, - }, - }, - computed: { - showButtons() { - return this.file.rawPath || - this.file.blamePath || - this.file.commitsPath || - this.file.permalink; - }, - rawDownloadButtonLabel() { - return this.file.binary ? 'Download' : 'Raw'; - }, - }, -}; -</script> - -<template> - <div - v-if="showButtons" - class="multi-file-editor-btn-group" - > - <a - :href="file.rawPath" - target="_blank" - class="btn btn-default btn-sm raw" - rel="noopener noreferrer"> - {{ rawDownloadButtonLabel }} - </a> - - <div - class="btn-group" - role="group" - aria-label="File actions" - > - <a - :href="file.blamePath" - class="btn btn-default btn-sm blame" - > - Blame - </a> - <a - :href="file.commitsPath" - class="btn btn-default btn-sm history" - > - History - </a> - <a - :href="file.permalink" - class="btn btn-default btn-sm permalink" - > - Permalink - </a> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue index 25d311142d5..97589e116c5 100644 --- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -1,27 +1,27 @@ <script> - import icon from '~/vue_shared/components/icon.vue'; - import tooltip from '~/vue_shared/directives/tooltip'; - import '~/lib/utils/datetime_utility'; +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import '~/lib/utils/datetime_utility'; - export default { - components: { - icon, +export default { + components: { + icon, + }, + directives: { + tooltip, + }, + props: { + file: { + type: Object, + required: true, }, - directives: { - tooltip, + }, + computed: { + lockTooltip() { + return `Locked by ${this.file.file_lock.user.name}`; }, - props: { - file: { - type: Object, - required: true, - }, - }, - computed: { - lockTooltip() { - return `Locked by ${this.file.file_lock.user.name}`; - }, - }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index c337bc813e6..304a73ed1ad 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,60 +1,64 @@ <script> - import { mapActions } from 'vuex'; +import { mapActions } from 'vuex'; - import fileIcon from '~/vue_shared/components/file_icon.vue'; - import icon from '~/vue_shared/components/icon.vue'; - import fileStatusIcon from './repo_file_status_icon.vue'; - import changedFileIcon from './changed_file_icon.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileStatusIcon from './repo_file_status_icon.vue'; +import ChangedFileIcon from './changed_file_icon.vue'; - export default { - components: { - fileStatusIcon, - fileIcon, - icon, - changedFileIcon, +export default { + components: { + FileStatusIcon, + FileIcon, + Icon, + ChangedFileIcon, + }, + props: { + tab: { + type: Object, + required: true, }, - props: { - tab: { - type: Object, - required: true, - }, + }, + data() { + return { + tabMouseOver: false, + }; + }, + computed: { + closeLabel() { + if (this.tab.changed || this.tab.tempFile) { + return `${this.tab.name} changed`; + } + return `Close ${this.tab.name}`; }, - data() { - return { - tabMouseOver: false, - }; - }, - computed: { - closeLabel() { - if (this.tab.changed || this.tab.tempFile) { - return `${this.tab.name} changed`; - } - return `Close ${this.tab.name}`; - }, - showChangedIcon() { - return this.tab.changed ? !this.tabMouseOver : false; - }, + showChangedIcon() { + return this.tab.changed ? !this.tabMouseOver : false; }, + }, + + methods: { + ...mapActions(['closeFile', 'updateDelayViewerUpdated', 'openPendingTab']), + clickFile(tab) { + this.updateDelayViewerUpdated(true); - methods: { - ...mapActions([ - 'closeFile', - ]), - clickFile(tab) { + if (tab.pending) { + this.openPendingTab(tab); + } else { this.$router.push(`/project${tab.url}`); - }, - mouseOverTab() { - if (this.tab.changed) { - this.tabMouseOver = true; - } - }, - mouseOutTab() { - if (this.tab.changed) { - this.tabMouseOver = false; - } - }, + } + }, + mouseOverTab() { + if (this.tab.changed) { + this.tabMouseOver = true; + } + }, + mouseOutTab() { + if (this.tab.changed) { + this.tabMouseOver = false; + } }, - }; + }, +}; </script> <template> @@ -66,7 +70,7 @@ <button type="button" class="multi-file-tab-close" - @click.stop.prevent="closeFile(tab.path)" + @click.stop.prevent="closeFile(tab)" :aria-label="closeLabel" > <icon @@ -82,7 +86,9 @@ <div class="multi-file-tab" - :class="{active : tab.active }" + :class="{ + active: tab.active + }" :title="tab.url" > <file-icon diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 8ea64ddf84a..7bd646ba9b0 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,42 +1,62 @@ <script> - import { mapActions } from 'vuex'; - import RepoTab from './repo_tab.vue'; - import EditorMode from './editor_mode_dropdown.vue'; +import { mapActions } from 'vuex'; +import RepoTab from './repo_tab.vue'; +import EditorMode from './editor_mode_dropdown.vue'; +import router from '../ide_router'; - export default { - components: { - RepoTab, - EditorMode, +export default { + components: { + RepoTab, + EditorMode, + }, + props: { + activeFile: { + type: Object, + required: true, }, - props: { - files: { - type: Array, - required: true, - }, - viewer: { - type: String, - required: true, - }, - hasChanges: { - type: Boolean, - required: true, - }, + files: { + type: Array, + required: true, }, - data() { - return { - showShadow: false, - }; + viewer: { + type: String, + required: true, }, - updated() { - if (!this.$refs.tabsScroller) return; - - this.showShadow = - this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + hasChanges: { + type: Boolean, + required: true, + }, + mergeRequestId: { + type: String, + required: false, + default: '', }, - methods: { - ...mapActions(['updateViewer']), + }, + data() { + return { + showShadow: false, + }; + }, + updated() { + if (!this.$refs.tabsScroller) return; + + this.showShadow = this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + }, + methods: { + ...mapActions(['updateViewer', 'removePendingTab']), + openFileViewer(viewer) { + this.updateViewer(viewer); + + if (this.activeFile.pending) { + return this.removePendingTab(this.activeFile).then(() => { + router.push(`/project${this.activeFile.url}`); + }); + } + + return null; }, - }; + }, +}; </script> <template> @@ -55,7 +75,8 @@ :viewer="viewer" :show-shadow="showShadow" :has-changes="hasChanges" - @click="updateViewer" + :merge-request-id="mergeRequestId" + @click="openFileViewer" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index faa690ecba0..5ea2a2f6825 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -1,67 +1,64 @@ <script> - import { mapActions, mapState } from 'vuex'; - import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { mapActions, mapState } from 'vuex'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; - export default { - components: { - PanelResizer, +export default { + components: { + PanelResizer, + }, + props: { + collapsible: { + type: Boolean, + required: true, }, - props: { - collapsible: { - type: Boolean, - required: true, - }, - initialWidth: { - type: Number, - required: true, - }, - minSize: { - type: Number, - required: false, - default: 200, - }, - side: { - type: String, - required: true, - }, + initialWidth: { + type: Number, + required: true, }, - data() { - return { - width: this.initialWidth, - }; + minSize: { + type: Number, + required: false, + default: 340, }, - computed: { - ...mapState({ - collapsed(state) { - return state[`${this.side}PanelCollapsed`]; - }, - }), - panelStyle() { - if (!this.collapsed) { - return { - width: `${this.width}px`, - }; - } - - return {}; - }, + side: { + type: String, + required: true, }, - methods: { - ...mapActions([ - 'setPanelCollapsedStatus', - 'setResizingStatus', - ]), - toggleFullbarCollapsed() { - if (this.collapsed && this.collapsible) { - this.setPanelCollapsedStatus({ - side: this.side, - collapsed: !this.collapsed, - }); - } + }, + data() { + return { + width: this.initialWidth, + }; + }, + computed: { + ...mapState({ + collapsed(state) { + return state[`${this.side}PanelCollapsed`]; }, + }), + panelStyle() { + if (!this.collapsed) { + return { + width: `${this.width}px`, + }; + } + + return {}; + }, + }, + methods: { + ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']), + toggleFullbarCollapsed() { + if (this.collapsed && this.collapsible) { + this.setPanelCollapsedStatus({ + side: this.side, + collapsed: !this.collapsed, + }); + } }, - maxSize: (window.innerWidth / 2), - }; + }, + maxSize: window.innerWidth / 2, +}; </script> <template> diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index db89c1d44db..20983666b4a 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -44,7 +44,7 @@ const router = new VueRouter({ component: EmptyRouterComponent, }, { - path: 'mr/:mrid', + path: 'merge_requests/:mrid', component: EmptyRouterComponent, }, ], @@ -76,10 +76,12 @@ router.beforeEach((to, from, next) => { .then(() => { if (to.params[0]) { const path = - to.params[0].slice(-1) === '/' - ? to.params[0].slice(0, -1) - : to.params[0]; - const treeEntry = store.state.entries[path]; + to.params[0].slice(-1) === '/' ? to.params[0].slice(0, -1) : to.params[0]; + const treeEntryKey = Object.keys(store.state.entries).find( + key => key === path && !store.state.entries[key].pending, + ); + const treeEntry = store.state.entries[treeEntryKey]; + if (treeEntry) { store.dispatch('handleTreeEntryAction', treeEntry); } @@ -96,6 +98,60 @@ router.beforeEach((to, from, next) => { ); throw e; }); + } else if (to.params.mrid) { + store.dispatch('updateViewer', 'mrdiff'); + + store + .dispatch('getMergeRequestData', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }) + .then(mr => { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: mr.source_branch, + }); + + return store.dispatch('getFiles', { + projectId: fullProjectId, + branchId: mr.source_branch, + }); + }) + .then(() => + store.dispatch('getMergeRequestVersions', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }), + ) + .then(() => + store.dispatch('getMergeRequestChanges', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }), + ) + .then(mrChanges => { + mrChanges.changes.forEach((change, ind) => { + const changeTreeEntry = store.state.entries[change.new_path]; + + if (changeTreeEntry) { + store.dispatch('setFileMrChange', { + file: changeTreeEntry, + mrChange: change, + }); + + if (ind < 10) { + store.dispatch('getFileData', { + path: change.new_path, + makeFileActive: ind === 0, + }); + } + } + }); + }) + .catch(e => { + flash('Error while loading the merge request. Please try again.'); + throw e; + }); } }) .catch(e => { diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 73cd684351c..e47adae99ed 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -13,25 +13,31 @@ export default class Model { (this.originalModel = this.monaco.editor.createModel( this.file.raw, undefined, - new this.monaco.Uri(null, null, `original/${this.file.path}`), + new this.monaco.Uri(null, null, `original/${this.file.key}`), )), (this.model = this.monaco.editor.createModel( this.content, undefined, - new this.monaco.Uri(null, null, this.file.path), + new this.monaco.Uri(null, null, this.file.key), )), ); + if (this.file.mrChange) { + this.disposable.add( + (this.baseModel = this.monaco.editor.createModel( + this.file.baseRaw, + undefined, + new this.monaco.Uri(null, null, `target/${this.file.path}`), + )), + ); + } this.events = new Map(); this.updateContent = this.updateContent.bind(this); this.dispose = this.dispose.bind(this); - eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose); - eventHub.$on( - `editor.update.model.content.${this.file.path}`, - this.updateContent, - ); + eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose); + eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent); } get url() { @@ -47,7 +53,7 @@ export default class Model { } get path() { - return this.file.path; + return this.file.key; } getModel() { @@ -58,6 +64,10 @@ export default class Model { return this.originalModel; } + getBaseModel() { + return this.baseModel; + } + setValue(value) { this.getModel().setValue(value); } @@ -78,13 +88,7 @@ export default class Model { this.disposable.dispose(); this.events.clear(); - eventHub.$off( - `editor.update.model.dispose.${this.file.path}`, - this.dispose, - ); - eventHub.$off( - `editor.update.model.content.${this.file.path}`, - this.updateContent, - ); + eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); + eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent); } } diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js index 57d5e59a88b..0e7b563b5d6 100644 --- a/app/assets/javascripts/ide/lib/common/model_manager.js +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -9,17 +9,17 @@ export default class ModelManager { this.models = new Map(); } - hasCachedModel(path) { - return this.models.has(path); + hasCachedModel(key) { + return this.models.has(key); } - getModel(path) { - return this.models.get(path); + getModel(key) { + return this.models.get(key); } addModel(file) { - if (this.hasCachedModel(file.path)) { - return this.getModel(file.path); + if (this.hasCachedModel(file.key)) { + return this.getModel(file.key); } const model = new Model(this.monaco, file); @@ -27,7 +27,7 @@ export default class ModelManager { this.disposable.add(model); eventHub.$on( - `editor.update.model.dispose.${file.path}`, + `editor.update.model.dispose.${file.key}`, this.removeCachedModel.bind(this, file), ); @@ -35,12 +35,9 @@ export default class ModelManager { } removeCachedModel(file) { - this.models.delete(file.path); + this.models.delete(file.key); - eventHub.$off( - `editor.update.model.dispose.${file.path}`, - this.removeCachedModel, - ); + eventHub.$off(`editor.update.model.dispose.${file.key}`, this.removeCachedModel); } dispose() { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 887dd7e39b1..001737d6ee8 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -69,6 +69,7 @@ export default class Editor { occurrencesHighlight: false, renderLineHighlight: 'none', hideCursorInOverviewRuler: true, + renderSideBySide: Editor.renderSideBySide(domElement), })), ); @@ -81,7 +82,7 @@ export default class Editor { } attachModel(model) { - if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') { + if (this.isDiffEditorType) { this.instance.setModel({ original: model.getOriginalModel(), modified: model.getModel(), @@ -109,11 +110,19 @@ export default class Editor { if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model); } + attachMergeRequestModel(model) { + this.instance.setModel({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + + this.monaco.editor.createDiffNavigator(this.instance, { + alwaysRevealFirst: true, + }); + } + setupMonacoTheme() { - this.monaco.editor.defineTheme( - gitlabTheme.themeName, - gitlabTheme.monacoTheme, - ); + this.monaco.editor.defineTheme(gitlabTheme.themeName, gitlabTheme.monacoTheme); this.monaco.editor.setTheme('gitlab'); } @@ -145,6 +154,7 @@ export default class Editor { updateDimensions() { this.instance.layout(); + this.updateDiffView(); } setPosition({ lineNumber, column }) { @@ -161,8 +171,22 @@ export default class Editor { onPositionChange(cb) { if (!this.instance.onDidChangeCursorPosition) return; - this.disposable.add( - this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)), - ); + this.disposable.add(this.instance.onDidChangeCursorPosition(e => cb(this.instance, e))); + } + + updateDiffView() { + if (!this.isDiffEditorType) return; + + this.instance.updateOptions({ + renderSideBySide: Editor.renderSideBySide(this.instance.getDomNode()), + }); + } + + get isDiffEditorType() { + return this.instance.getEditorType() === 'vs.editor.IDiffEditor'; + } + + static renderSideBySide(domElement) { + return domElement.offsetWidth >= 700; } } diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index a213862f9b3..9f895d49f2e 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -6,7 +6,7 @@ export const defaultEditorOptions = { minimap: { enabled: false, }, - wordWrap: 'bounded', + wordWrap: 'on', }; export default [ diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 5f1fb6cf843..a12e637616a 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -20,12 +20,35 @@ export default { return Promise.resolve(file.raw); } - return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + return Vue.http.get(file.rawPath, { params: { format: 'json' } }).then(res => res.text()); + }, + getBaseRawFileData(file, sha) { + if (file.tempFile) { + return Promise.resolve(file.baseRaw); + } + + if (file.baseRaw) { + return Promise.resolve(file.baseRaw); + } + + return Vue.http + .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { + params: { format: 'json' }, + }) .then(res => res.text()); }, getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); }, + getProjectMergeRequestData(projectId, mergeRequestId) { + return Api.mergeRequest(projectId, mergeRequestId); + }, + getProjectMergeRequestChanges(projectId, mergeRequestId) { + return Api.mergeRequestChanges(projectId, mergeRequestId); + }, + getProjectMergeRequestVersions(projectId, mergeRequestId) { + return Api.mergeRequestVersions(projectId, mergeRequestId); + }, getBranchData(projectId, currentBranchId) { return Api.branchSingle(projectId, currentBranchId); }, diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 7e920aa9f30..c6ba679d99c 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -6,8 +6,7 @@ import FilesDecoratorWorker from './workers/files_decorator_worker'; export const redirectToUrl = (_, url) => visitUrl(url); -export const setInitialData = ({ commit }, data) => - commit(types.SET_INITIAL_DATA, data); +export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const discardAllChanges = ({ state, commit, dispatch }) => { state.changedFiles.forEach(file => { @@ -22,7 +21,7 @@ export const discardAllChanges = ({ state, commit, dispatch }) => { }; export const closeAllFiles = ({ state, dispatch }) => { - state.openFiles.forEach(file => dispatch('closeFile', file.path)); + state.openFiles.forEach(file => dispatch('closeFile', file)); }; export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { @@ -43,14 +42,11 @@ export const createTempEntry = ( ) => new Promise(resolve => { const worker = new FilesDecoratorWorker(); - const fullName = - name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; + const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; if (state.entries[name]) { flash( - `The name "${name - .split('/') - .pop()}" is already taken in this directory.`, + `The name "${name.split('/').pop()}" is already taken in this directory.`, 'alert', document, null, @@ -119,3 +115,4 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => { export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; +export * from './actions/merge_request'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index ddc4b757bf9..1a17320a1ea 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -6,24 +6,34 @@ import * as types from '../mutation_types'; import router from '../../ide_router'; import { setPageTitle } from '../utils'; -export const closeFile = ({ commit, state, getters, dispatch }, path) => { - const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path); - const file = state.entries[path]; +export const closeFile = ({ commit, state, dispatch }, file) => { + const path = file.path; + const indexOfClosedFile = state.openFiles.findIndex(f => f.key === file.key); const fileWasActive = file.active; - commit(types.TOGGLE_FILE_OPEN, path); - commit(types.SET_FILE_ACTIVE, { path, active: false }); + if (file.pending) { + commit(types.REMOVE_PENDING_TAB, file); + } else { + commit(types.TOGGLE_FILE_OPEN, path); + commit(types.SET_FILE_ACTIVE, { path, active: false }); + } if (state.openFiles.length > 0 && fileWasActive) { const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; - const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path]; - - router.push(`/project${nextFileToOpen.url}`); + const nextFileToOpen = state.openFiles[nextIndexToOpen]; + + if (nextFileToOpen.pending) { + dispatch('updateViewer', 'diff'); + dispatch('openPendingTab', nextFileToOpen); + } else { + dispatch('updateDelayViewerUpdated', true); + router.push(`/project${nextFileToOpen.url}`); + } } else if (!state.openFiles.length) { router.push(`/project/${file.projectId}/tree/${file.branchId}/`); } - eventHub.$emit(`editor.update.model.dispose.${file.path}`); + eventHub.$emit(`editor.update.model.dispose.${file.key}`); }; export const setFileActive = ({ commit, state, getters, dispatch }, path) => { @@ -46,53 +56,63 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { commit(types.SET_CURRENT_BRANCH, file.branchId); }; -export const getFileData = ({ state, commit, dispatch }, file) => { +export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => { + const file = state.entries[path]; commit(types.TOGGLE_LOADING, { entry: file }); - return service .getFileData(file.url) .then(res => { const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - setPageTitle(pageTitle); return res.json(); }) .then(data => { commit(types.SET_FILE_DATA, { data, file }); - commit(types.TOGGLE_FILE_OPEN, file.path); - dispatch('setFileActive', file.path); + commit(types.TOGGLE_FILE_OPEN, path); + if (makeFileActive) dispatch('setFileActive', path); commit(types.TOGGLE_LOADING, { entry: file }); }) .catch(() => { commit(types.TOGGLE_LOADING, { entry: file }); - flash( - 'Error loading file data. Please try again.', - 'alert', - document, - null, - false, - true, - ); + flash('Error loading file data. Please try again.', 'alert', document, null, false, true); }); }; -export const getRawFileData = ({ commit, dispatch }, file) => - service - .getRawFileData(file) - .then(raw => { - commit(types.SET_FILE_RAW_DATA, { file, raw }); - }) - .catch(() => - flash( - 'Error loading file content. Please try again.', - 'alert', - document, - null, - false, - true, - ), - ); +export const setFileMrChange = ({ state, commit }, { file, mrChange }) => { + commit(types.SET_FILE_MERGE_REQUEST_CHANGE, { file, mrChange }); +}; + +export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) => { + const file = state.entries[path]; + return new Promise((resolve, reject) => { + service + .getRawFileData(file) + .then(raw => { + commit(types.SET_FILE_RAW_DATA, { file, raw }); + if (file.mrChange && file.mrChange.new_file === false) { + service + .getBaseRawFileData(file, baseSha) + .then(baseRaw => { + commit(types.SET_FILE_BASE_RAW_DATA, { + file, + baseRaw, + }); + resolve(raw); + }) + .catch(e => { + reject(e); + }); + } else { + resolve(raw); + } + }) + .catch(() => { + flash('Error loading file content. Please try again.'); + reject(); + }); + }); +}; export const changeFileContent = ({ state, commit }, { path, content }) => { const file = state.entries[path]; @@ -119,10 +139,7 @@ export const setFileEOL = ({ getters, commit }, { eol }) => { } }; -export const setEditorPosition = ( - { getters, commit }, - { editorRow, editorColumn }, -) => { +export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => { if (getters.activeFile) { commit(types.SET_FILE_POSITION, { file: getters.activeFile, @@ -132,6 +149,10 @@ export const setEditorPosition = ( } }; +export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { + commit(types.SET_FILE_VIEWMODE, { file, viewMode }); +}; + export const discardFileChanges = ({ state, commit }, path) => { const file = state.entries[path]; @@ -144,3 +165,23 @@ export const discardFileChanges = ({ state, commit }, path) => { eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); }; + +export const openPendingTab = ({ commit, getters, dispatch, state }, file) => { + if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') { + return false; + } + + commit(types.ADD_PENDING_TAB, { file }); + + dispatch('scrollToTab'); + + router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`); + + return true; +}; + +export const removePendingTab = ({ commit }, file) => { + commit(types.REMOVE_PENDING_TAB, file); + + eventHub.$emit(`editor.update.model.dispose.${file.key}`); +}; diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js new file mode 100644 index 00000000000..da73034fd7d --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -0,0 +1,84 @@ +import flash from '~/flash'; +import service from '../../services'; +import * as types from '../mutation_types'; + +export const getMergeRequestData = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { + service + .getProjectMergeRequestData(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST, { + projectPath: projectId, + mergeRequestId, + mergeRequest: data, + }); + if (!state.currentMergeRequestId) { + commit(types.SET_CURRENT_MERGE_REQUEST, mergeRequestId); + } + resolve(data); + }) + .catch(() => { + flash('Error loading merge request data. Please try again.'); + reject(new Error(`Merge Request not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId]); + } + }); + +export const getMergeRequestChanges = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId].changes.length || force) { + service + .getProjectMergeRequestChanges(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST_CHANGES, { + projectPath: projectId, + mergeRequestId, + changes: data, + }); + resolve(data); + }) + .catch(() => { + flash('Error loading merge request changes. Please try again.'); + reject(new Error(`Merge Request Changes not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId].changes); + } + }); + +export const getMergeRequestVersions = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId].versions.length || force) { + service + .getProjectMergeRequestVersions(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST_VERSIONS, { + projectPath: projectId, + mergeRequestId, + versions: data, + }); + resolve(data); + }) + .catch(() => { + flash('Error loading merge request versions. Please try again.'); + reject(new Error(`Merge Request Versions not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId].versions); + } + }); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 70a969a0325..6536be04f0a 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -2,9 +2,7 @@ import { normalizeHeaders } from '~/lib/utils/common_utils'; import flash from '~/flash'; import service from '../../services'; import * as types from '../mutation_types'; -import { - findEntry, -} from '../utils'; +import { findEntry } from '../utils'; import FilesDecoratorWorker from '../workers/files_decorator_worker'; export const toggleTreeOpen = ({ commit, dispatch }, path) => { @@ -21,23 +19,24 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => { dispatch('setFileActive', row.path); } else { - dispatch('getFileData', row); + dispatch('getFileData', { path: row.path }); } }; export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; - service.getTreeLastCommit(tree.lastCommitPath) - .then((res) => { + service + .getTreeLastCommit(tree.lastCommitPath) + .then(res => { const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); return res.json(); }) - .then((data) => { - data.forEach((lastCommit) => { + .then(data => { + data.forEach(lastCommit => { const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); if (entry) { @@ -50,44 +49,47 @@ export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = s .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true)); }; -export const getFiles = ( - { state, commit, dispatch }, - { projectId, branchId } = {}, -) => new Promise((resolve, reject) => { - if (!state.trees[`${projectId}/${branchId}`]) { - const selectedProject = state.projects[projectId]; - commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); - - service - .getFiles(selectedProject.web_url, branchId) - .then(res => res.json()) - .then((data) => { - const worker = new FilesDecoratorWorker(); - worker.addEventListener('message', (e) => { - const { entries, treeList } = e.data; - const selectedTree = state.trees[`${projectId}/${branchId}`]; - - commit(types.SET_ENTRIES, entries); - commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList }); - commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false }); - - worker.terminate(); - - resolve(); - }); - - worker.postMessage({ - data, - projectId, - branchId, +export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => + new Promise((resolve, reject) => { + if (!state.trees[`${projectId}/${branchId}`]) { + const selectedProject = state.projects[projectId]; + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + + service + .getFiles(selectedProject.web_url, branchId) + .then(res => res.json()) + .then(data => { + const worker = new FilesDecoratorWorker(); + worker.addEventListener('message', e => { + const { entries, treeList } = e.data; + const selectedTree = state.trees[`${projectId}/${branchId}`]; + + commit(types.SET_ENTRIES, entries); + commit(types.SET_DIRECTORY_DATA, { + treePath: `${projectId}/${branchId}`, + data: treeList, + }); + commit(types.TOGGLE_LOADING, { + entry: selectedTree, + forceValue: false, + }); + + worker.terminate(); + + resolve(); + }); + + worker.postMessage({ + data, + projectId, + branchId, + }); + }) + .catch(e => { + flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); + reject(e); }); - }) - .catch((e) => { - flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); - reject(e); - }); - } else { - resolve(); - } -}); - + } else { + resolve(); + } + }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index eba325a31df..a77cdbc13c8 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,10 +1,8 @@ -export const activeFile = state => - state.openFiles.find(file => file.active) || null; +export const activeFile = state => state.openFiles.find(file => file.active) || null; export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); -export const modifiedFiles = state => - state.changedFiles.filter(f => !f.tempFile); +export const modifiedFiles = state => state.changedFiles.filter(f => !f.tempFile); export const projectsWithTrees = state => Object.keys(state.projects).map(projectId => { @@ -23,8 +21,17 @@ export const projectsWithTrees = state => }; }); +export const currentMergeRequest = state => { + if (state.projects[state.currentProjectId]) { + return state.projects[state.currentProjectId].mergeRequests[state.currentMergeRequestId]; + } + return null; +}; + // eslint-disable-next-line no-confusing-arrow export const currentIcon = state => state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; export const hasChanges = state => !!state.changedFiles.length; + +export const hasMergeRequest = state => !!state.currentMergeRequestId; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index e28f190897c..e3f504e5ab0 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -11,6 +11,12 @@ export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; +// Merge Request Mutation Types +export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST'; +export const SET_CURRENT_MERGE_REQUEST = 'SET_CURRENT_MERGE_REQUEST'; +export const SET_MERGE_REQUEST_CHANGES = 'SET_MERGE_REQUEST_CHANGES'; +export const SET_MERGE_REQUEST_VERSIONS = 'SET_MERGE_REQUEST_VERSIONS'; + // Branch Mutation Types export const SET_BRANCH = 'SET_BRANCH'; export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; @@ -28,9 +34,11 @@ export const SET_FILE_DATA = 'SET_FILE_DATA'; export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; +export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE'; export const SET_FILE_EOL = 'SET_FILE_EOL'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; @@ -39,5 +47,9 @@ export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED'; export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; export const SET_ENTRIES = 'SET_ENTRIES'; export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY'; +export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE'; export const UPDATE_VIEWER = 'UPDATE_VIEWER'; export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; + +export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; +export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index da41fc9285c..5e5eb831662 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import projectMutations from './mutations/project'; +import mergeRequestMutation from './mutations/merge_request'; import fileMutations from './mutations/file'; import treeMutations from './mutations/tree'; import branchMutations from './mutations/branch'; @@ -11,10 +12,7 @@ export default { [types.TOGGLE_LOADING](state, { entry, forceValue = undefined }) { if (entry.path) { Object.assign(state.entries[entry.path], { - loading: - forceValue !== undefined - ? forceValue - : !state.entries[entry.path].loading, + loading: forceValue !== undefined ? forceValue : !state.entries[entry.path].loading, }); } else { Object.assign(entry, { @@ -83,9 +81,7 @@ export default { if (!foundEntry) { Object.assign(state.trees[`${projectId}/${branchId}`], { - tree: state.trees[`${projectId}/${branchId}`].tree.concat( - data.treeList, - ), + tree: state.trees[`${projectId}/${branchId}`].tree.concat(data.treeList), }); } }, @@ -100,6 +96,7 @@ export default { }); }, ...projectMutations, + ...mergeRequestMutation, ...fileMutations, ...treeMutations, ...branchMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 2500f13db7c..eeb14b5490c 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -5,6 +5,14 @@ export default { Object.assign(state.entries[path], { active, }); + + if (active && !state.entries[path].pending) { + Object.assign(state, { + openFiles: state.openFiles.map(f => + Object.assign(f, { active: f.pending ? false : f.active }), + ), + }); + } }, [types.TOGGLE_FILE_OPEN](state, path) { Object.assign(state.entries[path], { @@ -12,10 +20,14 @@ export default { }); if (state.entries[path].opened) { - state.openFiles.push(state.entries[path]); + Object.assign(state, { + openFiles: state.openFiles.filter(f => f.path !== path).concat(state.entries[path]), + }); } else { + const file = state.entries[path]; + Object.assign(state, { - openFiles: state.openFiles.filter(f => f.path !== path), + openFiles: state.openFiles.filter(f => f.key !== file.key), }); } }, @@ -28,6 +40,10 @@ export default { rawPath: data.raw_path, binary: data.binary, renderError: data.render_error, + raw: null, + baseRaw: null, + html: data.html, + size: data.size, }); }, [types.SET_FILE_RAW_DATA](state, { file, raw }) { @@ -35,6 +51,11 @@ export default { raw, }); }, + [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) { + Object.assign(state.entries[file.path], { + baseRaw, + }); + }, [types.UPDATE_FILE_CONTENT](state, { path, content }) { const changed = content !== state.entries[path].raw; @@ -59,6 +80,16 @@ export default { editorColumn, }); }, + [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { + Object.assign(state.entries[file.path], { + mrChange, + }); + }, + [types.SET_FILE_VIEWMODE](state, { file, viewMode }) { + Object.assign(state.entries[file.path], { + viewMode, + }); + }, [types.DISCARD_FILE_CHANGES](state, path) { Object.assign(state.entries[path], { content: state.entries[path].raw, @@ -80,4 +111,37 @@ export default { changed, }); }, + [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { + const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending); + let openFiles = state.openFiles.map(f => + Object.assign(f, { active: f.path === file.path, opened: false }), + ); + + if (!pendingTab) { + const openFile = openFiles.find(f => f.path === file.path); + + openFiles = openFiles.concat(openFile ? null : file).reduce((acc, f) => { + if (!f) return acc; + + if (f.path === file.path) { + return acc.concat({ + ...f, + active: true, + pending: true, + opened: true, + key: `${keyPrefix}-${f.key}`, + }); + } + + return acc.concat(f); + }, []); + } + + Object.assign(state, { openFiles }); + }, + [types.REMOVE_PENDING_TAB](state, file) { + Object.assign(state, { + openFiles: state.openFiles.filter(f => f.key !== file.key), + }); + }, }; diff --git a/app/assets/javascripts/ide/stores/mutations/merge_request.js b/app/assets/javascripts/ide/stores/mutations/merge_request.js new file mode 100644 index 00000000000..334819fe702 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/merge_request.js @@ -0,0 +1,33 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_MERGE_REQUEST](state, currentMergeRequestId) { + Object.assign(state, { + currentMergeRequestId, + }); + }, + [types.SET_MERGE_REQUEST](state, { projectPath, mergeRequestId, mergeRequest }) { + Object.assign(state.projects[projectPath], { + mergeRequests: { + [mergeRequestId]: { + ...mergeRequest, + active: true, + changes: [], + versions: [], + baseCommitSha: null, + }, + }, + }); + }, + [types.SET_MERGE_REQUEST_CHANGES](state, { projectPath, mergeRequestId, changes }) { + Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], { + changes, + }); + }, + [types.SET_MERGE_REQUEST_VERSIONS](state, { projectPath, mergeRequestId, versions }) { + Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], { + versions, + baseCommitSha: versions.length ? versions[0].base_commit_sha : null, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 2816562a919..284b39a2c72 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -11,6 +11,7 @@ export default { Object.assign(project, { tree: [], branches: {}, + mergeRequests: {}, active: true, }); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 6110f54951c..e5cc8814000 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -1,6 +1,7 @@ export default () => ({ currentProjectId: '', currentBranchId: '', + currentMergeRequestId: '', changedFiles: [], endpoints: {}, lastCommitMsg: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 487ea1ead8e..05a019de54f 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,5 +1,7 @@ export const dataStructure = () => ({ id: '', + // Key will contain a mixture of ID and path + // it can also contain a prefix `pending-` for files opened in review mode key: '', type: '', projectId: '', @@ -36,9 +38,12 @@ export const dataStructure = () => ({ editorColumn: 1, fileLanguage: '', eol: '', + viewMode: 'edit', + previewMode: null, + size: 0, }); -export const decorateData = (entity) => { +export const decorateData = entity => { const { id, projectId, @@ -55,9 +60,9 @@ export const decorateData = (entity) => { changed = false, parentTreeUrl = '', base64 = false, - + previewMode, file_lock, - + html, } = entity; return { @@ -78,19 +83,18 @@ export const decorateData = (entity) => { renderError, content, base64, - + previewMode, file_lock, - + html, }; }; -export const findEntry = (tree, type, name, prop = 'name') => tree.find( - f => f.type === type && f[prop] === name, -); +export const findEntry = (tree, type, name, prop = 'name') => + tree.find(f => f.type === type && f[prop] === name); export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); -export const setPageTitle = (title) => { +export const setPageTitle = title => { document.title = title; }; @@ -120,6 +124,11 @@ const sortTreesByTypeAndName = (a, b) => { return 0; }; -export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, { - tree: entity.tree.length ? sortTree(entity.tree) : [], -})).sort(sortTreesByTypeAndName); +export const sortTree = sortedTree => + sortedTree + .map(entity => + Object.assign(entity, { + tree: entity.tree.length ? sortTree(entity.tree) : [], + }), + ) + .sort(sortTreesByTypeAndName); diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js index a4cd1ab099f..a1673276900 100644 --- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js +++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js @@ -1,14 +1,8 @@ +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import { decorateData, sortTree } from '../utils'; self.addEventListener('message', e => { - const { - data, - projectId, - branchId, - tempFile = false, - content = '', - base64 = false, - } = e.data; + const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; const treeList = []; let file; @@ -19,9 +13,7 @@ self.addEventListener('message', e => { if (pathSplit.length > 0) { pathSplit.reduce((pathAcc, folderName) => { const parentFolder = acc[pathAcc[pathAcc.length - 1]]; - const folderPath = `${ - parentFolder ? `${parentFolder.path}/` : '' - }${folderName}`; + const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`; const foundEntry = acc[folderPath]; if (!foundEntry) { @@ -33,9 +25,7 @@ self.addEventListener('message', e => { path: folderPath, url: `/${projectId}/tree/${branchId}/${folderPath}/`, type: 'tree', - parentTreeUrl: parentFolder - ? parentFolder.url - : `/${projectId}/tree/${branchId}/`, + parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, tempFile, changed: tempFile, opened: tempFile, @@ -70,13 +60,12 @@ self.addEventListener('message', e => { path, url: `/${projectId}/blob/${branchId}/${path}`, type: 'blob', - parentTreeUrl: fileFolder - ? fileFolder.url - : `/${projectId}/blob/${branchId}`, + parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, tempFile, changed: tempFile, content, base64, + previewMode: viewerInformationForPath(blobName), }); Object.assign(acc, { diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 172de6b3679..af47056d98f 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -45,7 +45,7 @@ return `#${this.job.runner.id}`; }, hasTimeout() { - return this.job.metadata != null && this.job.metadata.timeout_human_readable !== ''; + return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; }, timeout() { if (this.job.metadata == null) { diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 824d3f7ca09..d0050abb8e9 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -10,6 +10,7 @@ import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import DropdownUtils from './filtered_search/dropdown_utils'; import CreateLabelDropdown from './create_label'; import flash from './flash'; +import ModalStore from './boards/stores/modal_store'; export default class LabelsSelect { constructor(els, options = {}) { @@ -350,7 +351,7 @@ export default class LabelsSelect { } if ($dropdown.closest('.add-issues-modal').length) { - boardsModel = gl.issueBoards.ModalStore.store.filter; + boardsModel = ModalStore.store.filter; } if (boardsModel) { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 0830ebe9e4e..9ff2042475b 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -33,6 +33,7 @@ export const checkPageAndAction = (page, action) => { export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); +export const isInEpicPage = () => checkPageAndAction('epics', 'show'); export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index de65ea15a60..914de9de940 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,7 +1,12 @@ -/* eslint-disable import/prefer-default-export */ +import $ from 'jquery'; +import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils'; + +const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); export const addClassIfElementExists = (element, className) => { if (element) { element.classList.add(className); } }; + +export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions(); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 94d03621bff..b54ecd2d543 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -7,7 +7,8 @@ * @param {String} text * @returns {String} */ -export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); +export const addDelimiter = text => + (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); /** * Returns '99+' for numbers bigger than 99. @@ -22,7 +23,8 @@ export const highCountTrim = count => (count > 99 ? '99+' : count); * @param {String} string * @requires {String} */ -export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); +export const humanize = string => + string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); /** * Adds an 's' to the end of the string when count is bigger than 0 @@ -53,7 +55,7 @@ export const slugify = str => str.trim().toLowerCase(); * @param {Number} maxLength * @returns {String} */ -export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`; +export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; /** * Capitalizes first character @@ -80,3 +82,15 @@ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, re * @param {*} string */ export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); + +/** + * Converts a sentence to lower case from the second word onwards + * e.g. Hello World => Hello world + * + * @param {*} string + */ +export const convertToSentenceCase = string => { + const splitWord = string.split(' ').map((word, index) => (index > 0 ? word.toLowerCase() : word)); + + return splitWord.join(' '); +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index a266bb6771f..dd17544b656 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -51,7 +51,7 @@ export function removeParams(params) { const url = document.createElement('a'); url.href = window.location.href; - params.forEach((param) => { + params.forEach(param => { url.search = removeParamQueryString(url.search, param); }); @@ -83,3 +83,11 @@ export function refreshCurrentPage() { export function redirectTo(url) { return window.location.assign(url); } + +export function webIDEUrl(route = undefined) { + let returnUrl = `${gon.relative_url_root}/-/ide/`; + if (route) { + returnUrl += `project${route}`; + } + return returnUrl; +} diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index add07c156a4..d0a2b27b0e6 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -6,6 +6,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; import { timeFor } from './lib/utils/datetime_utility'; +import ModalStore from './boards/stores/modal_store'; export default class MilestoneSelect { constructor(currentProject, els, options = {}) { @@ -94,10 +95,10 @@ export default class MilestoneSelect { if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } - $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); + $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active'); }), renderRow: milestone => ` - <li data-milestone-id="${milestone.name}"> + <li data-milestone-id="${_.escape(milestone.name)}"> <a href='#' class='dropdown-menu-milestone-link'> ${_.escape(milestone.title)} </a> @@ -125,7 +126,6 @@ export default class MilestoneSelect { return milestone.id; } }, - isSelected: milestone => milestone.name === selectedMilestone, hidden: () => { $selectBox.hide(); // display:block overrides the hide-collapse rule @@ -137,7 +137,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; } $('a.is-active', $el).removeClass('is-active'); - $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); + $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: (clickEvent) => { @@ -158,13 +158,14 @@ export default class MilestoneSelect { const isMRIndex = (page === page && page === 'projects:merge_requests:index'); const isSelecting = (selected.name !== selectedMilestone); selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault; + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); return; } if ($dropdown.closest('.add-issues-modal').length) { - boardsStore = gl.issueBoards.ModalStore.store.filter; + boardsStore = ModalStore.store.filter; } if (boardsStore) { diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 04d546fafa0..f93b1da4f58 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -1,8 +1,10 @@ <script> import { scaleLinear, scaleTime } from 'd3-scale'; import { axisLeft, axisBottom } from 'd3-axis'; +import _ from 'underscore'; import { max, extent } from 'd3-array'; import { select } from 'd3-selection'; +import GraphAxis from './graph/axis.vue'; import GraphLegend from './graph/legend.vue'; import GraphFlag from './graph/flag.vue'; import GraphDeployment from './graph/deployment.vue'; @@ -18,10 +20,11 @@ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select } export default { components: { - GraphLegend, + GraphAxis, GraphFlag, GraphDeployment, GraphPath, + GraphLegend, }, mixins: [MonitoringMixin], props: { @@ -138,7 +141,7 @@ export default { this.legendTitle = query.label || 'Average'; this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right; this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - this.baseGraphHeight = this.graphHeight; + this.baseGraphHeight = this.graphHeight - 50; this.baseGraphWidth = this.graphWidth; // pixel offsets inside the svg and outside are not 1:1 @@ -177,10 +180,8 @@ export default { this.graphHeightOffset, ); - if (!this.showLegend) { - this.baseGraphHeight -= 50; - } else if (this.timeSeries.length > 3) { - this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; + if (_.findWhere(this.timeSeries, { renderCanary: true })) { + this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true })); } const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); @@ -251,17 +252,13 @@ export default { class="y-axis" transform="translate(70, 20)" /> - <graph-legend + <graph-axis :graph-width="graphWidth" :graph-height="graphHeight" :margin="margin" :measurements="measurements" - :legend-title="legendTitle" :y-axis-label="yAxisLabel" - :time-series="timeSeries" :unit-of-display="unitOfDisplay" - :current-data-index="currentDataIndex" - :show-legend-group="showLegend" /> <svg class="graph-data" @@ -306,5 +303,10 @@ export default { :deployment-flag-data="deploymentFlagData" /> </div> + <graph-legend + v-if="showLegend" + :legend-title="legendTitle" + :time-series="timeSeries" + /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue new file mode 100644 index 00000000000..fc4b3689dfd --- /dev/null +++ b/app/assets/javascripts/monitoring/components/graph/axis.vue @@ -0,0 +1,142 @@ +<script> +import { convertToSentenceCase } from '~/lib/utils/text_utility'; +import { s__ } from '~/locale'; + +export default { + props: { + graphWidth: { + type: Number, + required: true, + }, + graphHeight: { + type: Number, + required: true, + }, + margin: { + type: Object, + required: true, + }, + measurements: { + type: Object, + required: true, + }, + yAxisLabel: { + type: String, + required: true, + }, + unitOfDisplay: { + type: String, + required: true, + }, + }, + data() { + return { + yLabelWidth: 0, + yLabelHeight: 0, + }; + }, + computed: { + textTransform() { + const yCoordinate = + (this.graphHeight - + this.margin.top + + this.measurements.axisLabelLineOffset) / + 2 || 0; + + return `translate(15, ${yCoordinate}) rotate(-90)`; + }, + + rectTransform() { + const yCoordinate = + (this.graphHeight - + this.margin.top + + this.measurements.axisLabelLineOffset) / + 2 + + this.yLabelWidth / 2 || 0; + + return `translate(0, ${yCoordinate}) rotate(-90)`; + }, + + xPosition() { + return ( + (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - + this.margin.right || 0 + ); + }, + + yPosition() { + return ( + this.graphHeight - + this.margin.top + + this.measurements.axisLabelLineOffset || 0 + ); + }, + + yAxisLabelSentenceCase() { + return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`; + }, + + timeString() { + return s__('PrometheusDashboard|Time'); + }, + }, + mounted() { + this.$nextTick(() => { + const bbox = this.$refs.ylabel.getBBox(); + this.yLabelWidth = bbox.width + 10; // Added some padding + this.yLabelHeight = bbox.height + 5; + }); + }, +}; +</script> +<template> + <g class="axis-label-container"> + <line + class="label-x-axis-line" + stroke="#000000" + stroke-width="1" + x1="10" + :y1="yPosition" + :x2="graphWidth + 20" + :y2="yPosition" + /> + <line + class="label-y-axis-line" + stroke="#000000" + stroke-width="1" + x1="10" + y1="0" + :x2="10" + :y2="yPosition" + /> + <rect + class="rect-axis-text" + :transform="rectTransform" + :width="yLabelWidth" + :height="yLabelHeight" + /> + <text + class="label-axis-text y-label-text" + text-anchor="middle" + :transform="textTransform" + ref="ylabel" + > + {{ yAxisLabelSentenceCase }} + </text> + <rect + class="rect-axis-text" + :x="xPosition + 60" + :y="graphHeight - 80" + width="35" + height="50" + /> + <text + class="label-axis-text x-label-text" + :x="xPosition + 60" + :y="yPosition" + dy=".35em" + > + {{ timeString }} + </text> + </g> +</template> diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index 906c7c51f52..b8202e25685 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -1,11 +1,13 @@ <script> import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; import { formatRelevantDigits } from '../../../lib/utils/number_utils'; -import icon from '../../../vue_shared/components/icon.vue'; +import Icon from '../../../vue_shared/components/icon.vue'; +import TrackLine from './track_line.vue'; export default { components: { - icon, + Icon, + TrackLine, }, props: { currentXCoordinate: { @@ -107,11 +109,6 @@ export default { } return `series ${index + 1}`; }, - strokeDashArray(type) { - if (type === 'dashed') return '6, 3'; - if (type === 'dotted') return '3, 3'; - return null; - }, }, }; </script> @@ -160,28 +157,13 @@ export default { </div> </div> <div class="popover-content"> - <table> + <table class="prometheus-table"> <tr v-for="(series, index) in timeSeries" :key="index" > - <td> - <svg - width="15" - height="6" - > - <line - :stroke="series.lineColor" - :stroke-dasharray="strokeDashArray(series.lineStyle)" - stroke-width="4" - x1="0" - x2="15" - y1="2" - y2="2" - /> - </svg> - </td> - <td>{{ seriesMetricLabel(index, series) }}</td> + <track-line :track="series"/> + <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td> <td> <strong>{{ seriesMetricValue(series) }}</strong> </td> diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index a7a058a9203..da9280cf1f1 100644 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -1,204 +1,72 @@ <script> -import { formatRelevantDigits } from '../../../lib/utils/number_utils'; +import TrackLine from './track_line.vue'; +import TrackInfo from './track_info.vue'; export default { + components: { + TrackLine, + TrackInfo, + }, props: { - graphWidth: { - type: Number, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - margin: { - type: Object, - required: true, - }, - measurements: { - type: Object, - required: true, - }, legendTitle: { type: String, required: true, }, - yAxisLabel: { - type: String, - required: true, - }, timeSeries: { type: Array, required: true, }, - unitOfDisplay: { - type: String, - required: true, - }, - currentDataIndex: { - type: Number, - required: true, - }, - showLegendGroup: { - type: Boolean, - required: false, - default: true, - }, - }, - data() { - return { - yLabelWidth: 0, - yLabelHeight: 0, - seriesXPosition: 0, - metricUsageXPosition: 0, - }; - }, - computed: { - textTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0; - - return `translate(15, ${yCoordinate}) rotate(-90)`; - }, - rectTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 + - this.yLabelWidth / 2 || 0; - - return `translate(0, ${yCoordinate}) rotate(-90)`; - }, - xPosition() { - return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0; - }, - yPosition() { - return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0; - }, - }, - mounted() { - this.$nextTick(() => { - const bbox = this.$refs.ylabel.getBBox(); - this.metricUsageXPosition = 0; - this.seriesXPosition = 0; - if (this.$refs.legendTitleSvg != null) { - this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width; - } - if (this.$refs.seriesTitleSvg != null) { - this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width; - } - this.yLabelWidth = bbox.width + 10; // Added some padding - this.yLabelHeight = bbox.height + 5; - }); }, methods: { - translateLegendGroup(index) { - return `translate(0, ${12 * index})`; - }, - formatMetricUsage(series) { - const value = - series.values[this.currentDataIndex] && series.values[this.currentDataIndex].value; - if (isNaN(value)) { - return '-'; - } - return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`; - }, - createSeriesString(index, series) { - if (series.metricTag) { - return `${series.metricTag} ${this.formatMetricUsage(series)}`; - } - return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`; - }, - strokeDashArray(type) { - if (type === 'dashed') return '6, 3'; - if (type === 'dotted') return '3, 3'; - return null; + isStable(track) { + return { + 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary, + }; }, }, }; </script> <template> - <g class="axis-label-container"> - <line - class="label-x-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - :y1="yPosition" - :x2="graphWidth + 20" - :y2="yPosition" - /> - <line - class="label-y-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - y1="0" - :x2="10" - :y2="yPosition" - /> - <rect - class="rect-axis-text" - :transform="rectTransform" - :width="yLabelWidth" - :height="yLabelHeight" - /> - <text - class="label-axis-text y-label-text" - text-anchor="middle" - :transform="textTransform" - ref="ylabel" - > - {{ yAxisLabel }} - </text> - <rect - class="rect-axis-text" - :x="xPosition + 60" - :y="graphHeight - 80" - width="35" - height="50" - /> - <text - class="label-axis-text x-label-text" - :x="xPosition + 60" - :y="yPosition" - dy=".35em" - > - Time - </text> - <template v-if="showLegendGroup"> - <g - class="legend-group" + <div class="prometheus-graph-legends prepend-left-10"> + <table class="prometheus-table"> + <tr v-for="(series, index) in timeSeries" :key="index" - :transform="translateLegendGroup(index)" + v-if="series.shouldRenderLegend" + :class="isStable(series)" > - <line - :stroke="series.lineColor" - :stroke-width="measurements.legends.height" - :stroke-dasharray="strokeDashArray(series.lineStyle)" - :x1="measurements.legends.offsetX" - :x2="measurements.legends.offsetX + measurements.legends.width" - :y1="graphHeight - measurements.legends.offsetY" - :y2="graphHeight - measurements.legends.offsetY" - /> - <text - v-if="timeSeries.length > 1" - class="legend-metric-title" - ref="legendTitleSvg" - x="38" - :y="graphHeight - 30" - > - {{ createSeriesString(index, series) }} - </text> - <text - v-else + <td> + <strong v-if="series.renderCanary">{{ series.trackName }}</strong> + </td> + <track-line :track="series" /> + <td class="legend-metric-title" - ref="legendTitleSvg" - x="38" - :y="graphHeight - 30" - > - {{ legendTitle }} {{ formatMetricUsage(series) }} - </text> - </g> - </template> - </g> + v-if="timeSeries.length > 1"> + <track-info + :track="series" + v-if="series.metricTag" /> + <track-info + v-else + :track="series"> + <strong>{{ legendTitle }}</strong> series {{ index + 1 }} + </track-info> + </td> + <td v-else> + <track-info :track="series"> + <strong>{{ legendTitle }}</strong> + </track-info> + </td> + <template v-for="(track, trackIndex) in series.tracksLegend"> + <track-line + :track="track" + :key="`track-line-${trackIndex}`"/> + <td :key="`track-info-${trackIndex}`"> + <track-info + class="legend-metric-title" + :track="track" /> + </td> + </template> + </tr> + </table> + </div> </template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue new file mode 100644 index 00000000000..ec1c2222af9 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/graph/track_info.vue @@ -0,0 +1,29 @@ +<script> +import { formatRelevantDigits } from '~/lib/utils/number_utils'; + +export default { + name: 'TrackInfo', + props: { + track: { + type: Object, + required: true, + }, + }, + computed: { + summaryMetrics() { + return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits( + this.track.max, + )}`; + }, + }, +}; +</script> +<template> + <span> + <slot> + <strong> {{ track.metricTag }} </strong> + </slot> + {{ summaryMetrics }} + </span> +</template> + diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue new file mode 100644 index 00000000000..79b322e2e42 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/graph/track_line.vue @@ -0,0 +1,36 @@ +<script> +export default { + name: 'TrackLine', + props: { + track: { + type: Object, + required: true, + }, + }, + computed: { + stylizedLine() { + if (this.track.lineStyle === 'dashed') return '6, 3'; + if (this.track.lineStyle === 'dotted') return '3, 3'; + return null; + }, + }, +}; +</script> +<template> + <td> + <svg + width="15" + height="6"> + <line + :stroke-dasharray="stylizedLine" + :stroke="track.lineColor" + stroke-width="4" + :x1="0" + :x2="15" + :y1="2" + :y2="2" + /> + </svg> + </td> +</template> + diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 854636e9a89..535c415cd6d 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -1,7 +1,7 @@ import _ from 'underscore'; function sortMetrics(metrics) { - return _.chain(metrics).sortBy('weight').sortBy('title').value(); + return _.chain(metrics).sortBy('title').sortBy('weight').value(); } function normalizeMetrics(metrics) { diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index b5b8e3c255d..8a93c7e6bae 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -1,10 +1,21 @@ import _ from 'underscore'; import { scaleLinear, scaleTime } from 'd3-scale'; import { line, area, curveLinear } from 'd3-shape'; -import { extent, max } from 'd3-array'; +import { extent, max, sum } from 'd3-array'; import { timeMinute } from 'd3-time'; - -const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute }; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +const d3 = { + scaleLinear, + scaleTime, + line, + area, + curveLinear, + extent, + max, + timeMinute, + sum, +}; const defaultColorPalette = { blue: ['#1f78d1', '#8fbce8'], @@ -20,6 +31,8 @@ const defaultStyleOrder = ['solid', 'dashed', 'dotted']; function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) { let usedColors = []; + let renderCanary = false; + const timeSeriesParsed = []; function pickColor(name) { let pick; @@ -38,16 +51,23 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom return defaultColorPalette[pick]; } - return query.result.map((timeSeries, timeSeriesNumber) => { + query.result.forEach((timeSeries, timeSeriesNumber) => { let metricTag = ''; let lineColor = ''; let areaColor = ''; + let shouldRenderLegend = true; + const timeSeriesValues = timeSeries.values.map(d => d.value); + const maximumValue = d3.max(timeSeriesValues); + const accum = d3.sum(timeSeriesValues); + const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable'); + + if (trackName === 'Canary') { + renderCanary = true; + } - const timeSeriesScaleX = d3.scaleTime() - .range([0, graphWidth - 70]); + const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]); - const timeSeriesScaleY = d3.scaleLinear() - .range([graphHeight - graphHeightOffset, 0]); + const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]); timeSeriesScaleX.domain(xDom); timeSeriesScaleX.ticks(d3.timeMinute, 60); @@ -55,13 +75,15 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom const defined = d => !isNaN(d.value) && d.value != null; - const lineFunction = d3.line() + const lineFunction = d3 + .line() .defined(defined) .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate .x(d => timeSeriesScaleX(d.time)) .y(d => timeSeriesScaleY(d.value)); - const areaFunction = d3.area() + const areaFunction = d3 + .area() .defined(defined) .curve(d3.curveLinear) .x(d => timeSeriesScaleX(d.time)) @@ -69,38 +91,62 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom .y1(d => timeSeriesScaleY(d.value)); const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; - const seriesCustomizationData = query.series != null && - _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); + const seriesCustomizationData = + query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); if (seriesCustomizationData) { metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; [lineColor, areaColor] = pickColor(seriesCustomizationData.color); + shouldRenderLegend = false; } else { metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; [lineColor, areaColor] = pickColor(); + if (timeSeriesParsed.length > 1) { + shouldRenderLegend = false; + } } - if (query.track) { - metricTag += ` - ${query.track}`; + if (!shouldRenderLegend) { + if (!timeSeriesParsed[0].tracksLegend) { + timeSeriesParsed[0].tracksLegend = []; + } + timeSeriesParsed[0].tracksLegend.push({ + max: maximumValue, + average: accum / timeSeries.values.length, + lineStyle, + lineColor, + metricTag, + }); } - return { + timeSeriesParsed.push({ linePath: lineFunction(timeSeries.values), areaPath: areaFunction(timeSeries.values), timeSeriesScaleX, values: timeSeries.values, + max: maximumValue, + average: accum / timeSeries.values.length, lineStyle, lineColor, areaColor, metricTag, - }; + trackName, + shouldRenderLegend, + renderCanary, + }); }); + + return timeSeriesParsed; } export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { - const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat( - query.result.reduce((allResults, result) => allResults.concat(result.values), []), - ), []); + const allValues = queries.reduce( + (allQueryResults, query) => + allQueryResults.concat( + query.result.reduce((allResults, result) => allResults.concat(result.values), []), + ), + [], + ); const xDom = d3.extent(allValues, d => d.time); const yDom = [0, d3.max(allValues.map(d => d.value))]; diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 096c4ef5f31..e3c5bf06b3d 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -13,8 +13,11 @@ export default function initMrNotes() { data() { const notesDataset = document.getElementById('js-vue-mr-discussions') .dataset; + const noteableData = JSON.parse(notesDataset.noteableData); + noteableData.noteableType = notesDataset.noteableType; + return { - noteableData: JSON.parse(notesDataset.noteableData), + noteableData, currentUserData: JSON.parse(notesDataset.currentUserData), notesData: JSON.parse(notesDataset.notesData), }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b0573510ff9..ac70ddb3ff4 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1190,12 +1190,12 @@ export default class Notes { addForm = false; let lineTypeSelector = ''; rowCssToAdd = - '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>'; + '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content discussion-notes"></div></td></tr>'; // In parallel view, look inside the correct left/right pane if (this.isParallelView()) { lineTypeSelector = `.${lineType}`; rowCssToAdd = - '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>'; + '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content discussion-notes"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content discussion-notes"></div></td></tr>'; } const notesContentSelector = `.notes_content${lineTypeSelector} .content`; let notesContent = targetRow.find(notesContentSelector); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 90dcafd75b7..648fa6ff804 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -99,6 +99,10 @@ export default { 'js-note-target-reopen': !this.isOpen, }; }, + supportQuickActions() { + // Disable quick actions support for Epics + return this.noteableType !== constants.EPIC_NOTEABLE_TYPE; + }, markdownDocsPath() { return this.getNotesData.markdownDocsPath; }, @@ -355,7 +359,7 @@ Please check your network connection and try again.`; name="note[note]" class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea" - data-supports-quick-actions="true" + :data-supports-quick-actions="supportQuickActions" aria-label="Description" v-model="note" ref="textarea" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index e0f883a8e08..476b15aca4a 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -258,9 +258,7 @@ Please check your network connection and try again.`; :key="note.id" /> </ul> - <div - :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder"> + <div class="discussion-reply-holder"> <template v-if="!isReplying && canReply"> <div class="btn-group-justified discussion-with-resolve-btn" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index a90c6d6381d..ebfc827ac57 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -49,12 +49,7 @@ export default { computed: { ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), noteableType() { - // FIXME -- @fatihacet Get this from JSON data. - const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; - - return this.noteableData.merge_params - ? MERGE_REQUEST_NOTEABLE_TYPE - : ISSUE_NOTEABLE_TYPE; + return this.noteableData.noteableType; }, allNotes() { if (this.isLoading) { diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index f4f407ffd8a..c4de4826eda 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -10,6 +10,13 @@ export const CLOSED = 'closed'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const ISSUE_NOTEABLE_TYPE = 'issue'; +export const EPIC_NOTEABLE_TYPE = 'epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; + +export const NOTEABLE_TYPE_MAPPING = { + Issue: ISSUE_NOTEABLE_TYPE, + MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, + Epic: EPIC_NOTEABLE_TYPE, +}; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index f90775d0157..e4121f151db 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -12,8 +12,11 @@ document.addEventListener( data() { const notesDataset = document.getElementById('js-vue-notes').dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); + const noteableData = JSON.parse(notesDataset.noteableData); let currentUserData = {}; + noteableData.noteableType = notesDataset.noteableType; + if (parsedUserData) { currentUserData = { id: parsedUserData.id, @@ -25,7 +28,7 @@ document.addEventListener( } return { - noteableData: JSON.parse(notesDataset.noteableData), + noteableData, currentUserData, notesData: JSON.parse(notesDataset.notesData), }; diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js index 0da4ff49f08..b68543d71c8 100644 --- a/app/assets/javascripts/notes/mixins/noteable.js +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -9,14 +9,7 @@ export default { }, computed: { noteableType() { - switch (this.note.noteable_type) { - case 'MergeRequest': - return constants.MERGE_REQUEST_NOTEABLE_TYPE; - case 'Issue': - return constants.ISSUE_NOTEABLE_TYPE; - default: - return ''; - } + return constants.NOTEABLE_TYPE_MAPPING[this.note.noteable_type]; }, }, }; diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index d149b307e7f..914f804fdd3 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -5,6 +5,7 @@ import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, + isGroupDecendent: true, }); projectSelect(); }); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index a5cc1f34b63..1600faa3611 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -5,6 +5,7 @@ import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, + isGroupDecendent: true, }); projectSelect(); }); diff --git a/app/assets/javascripts/pages/groups/settings/badges/index.js b/app/assets/javascripts/pages/groups/settings/badges/index.js new file mode 100644 index 00000000000..74e96ee4a8f --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/badges/index.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { GROUP_BADGE } from '~/badges/constants'; +import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; + +Vue.use(Translate); + +document.addEventListener('DOMContentLoaded', () => { + mountBadgeSettings(GROUP_BADGE); +}); diff --git a/app/assets/javascripts/pages/projects/settings/badges/index/index.js b/app/assets/javascripts/pages/projects/settings/badges/index/index.js new file mode 100644 index 00000000000..30469550866 --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/badges/index/index.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { PROJECT_BADGE } from '~/badges/constants'; +import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; + +Vue.use(Translate); + +document.addEventListener('DOMContentLoaded', () => { + mountBadgeSettings(PROJECT_BADGE); +}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js new file mode 100644 index 00000000000..ffc84dc106b --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js @@ -0,0 +1,3 @@ +import initForm from '../form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js new file mode 100644 index 00000000000..a5c17ab322c --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -0,0 +1,19 @@ +/* eslint-disable no-new */ + +import ProtectedTagCreate from '~/protected_tags/protected_tag_create'; +import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list'; +import initSettingsPanels from '~/settings_panels'; +import initDeployKeys from '~/deploy_keys'; +import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; +import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list'; +import DueDateSelectors from '~/due_date_select'; + +export default () => { + new ProtectedTagCreate(); + new ProtectedTagEditList(); + initDeployKeys(); + initSettingsPanels(); + new ProtectedBranchCreate(); // eslint-disable-line no-new + new ProtectedBranchEditList(); // eslint-disable-line no-new + new DueDateSelectors(); +}; diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index 788d86d1192..ffc84dc106b 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,17 +1,3 @@ -/* eslint-disable no-new */ +import initForm from '../form'; -import ProtectedTagCreate from '~/protected_tags/protected_tag_create'; -import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list'; -import initSettingsPanels from '~/settings_panels'; -import initDeployKeys from '~/deploy_keys'; -import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; -import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list'; - -document.addEventListener('DOMContentLoaded', () => { - new ProtectedTagCreate(); - new ProtectedTagEditList(); - initDeployKeys(); - initSettingsPanels(); - new ProtectedBranchCreate(); // eslint-disable-line no-new - new ProtectedBranchEditList(); // eslint-disable-line no-new -}); +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/shared/mount_badge_settings.js b/app/assets/javascripts/pages/shared/mount_badge_settings.js new file mode 100644 index 00000000000..1397c0834ff --- /dev/null +++ b/app/assets/javascripts/pages/shared/mount_badge_settings.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import BadgeSettings from '~/badges/components/badge_settings.vue'; +import store from '~/badges/store'; + +export default kind => { + const badgeSettingsElement = document.getElementById('badge-settings'); + + store.dispatch('loadBadges', { + kind, + apiEndpointUrl: badgeSettingsElement.dataset.apiEndpointUrl, + docsUrl: badgeSettingsElement.dataset.docsUrl, + }); + + return new Vue({ + el: badgeSettingsElement, + store, + components: { + BadgeSettings, + }, + render(createElement) { + return createElement(BadgeSettings); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index d7effb27bff..e99d949801f 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -1,60 +1,72 @@ <script> - import tooltip from '../../../vue_shared/directives/tooltip'; - import icon from '../../../vue_shared/components/icon.vue'; - import { dasherize } from '../../../lib/utils/text_utility'; - /** - * Renders either a cancel, retry or play icon pointing to the given path. - * TODO: Remove UJS from here and use an async request instead. - */ - export default { - components: { - icon, - }, +import $ from 'jquery'; +import tooltip from '../../../vue_shared/directives/tooltip'; +import Icon from '../../../vue_shared/components/icon.vue'; +import { dasherize } from '../../../lib/utils/text_utility'; +import eventHub from '../../event_hub'; +/** + * Renders either a cancel, retry or play icon pointing to the given path. + */ +export default { + components: { + Icon, + }, - directives: { - tooltip, - }, + directives: { + tooltip, + }, - props: { - tooltipText: { - type: String, - required: true, - }, + props: { + tooltipText: { + type: String, + required: true, + }, - link: { - type: String, - required: true, - }, + link: { + type: String, + required: true, + }, - actionMethod: { - type: String, - required: true, - }, + actionIcon: { + type: String, + required: true, + }, - actionIcon: { - type: String, - required: true, - }, + buttonDisabled: { + type: String, + required: false, + default: null, + }, + }, + computed: { + cssClass() { + const actionIconDash = dasherize(this.actionIcon); + return `${actionIconDash} js-icon-${actionIconDash}`; + }, + isDisabled() { + return this.buttonDisabled === this.link; }, + }, - computed: { - cssClass() { - const actionIconDash = dasherize(this.actionIcon); - return `${actionIconDash} js-icon-${actionIconDash}`; - }, + methods: { + onClickAction() { + $(this.$el).tooltip('hide'); + eventHub.$emit('graphAction', this.link); }, - }; + }, +}; </script> <template> - <a + <button + type="button" + @click="onClickAction" v-tooltip - :data-method="actionMethod" :title="tooltipText" - :href="link" - class="ci-action-icon-container ci-action-icon-wrapper" + class="btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper" :class="cssClass" data-container="body" + :disabled="isDisabled" > <icon :name="actionIcon" /> - </a> + </button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index ab84711d4a2..ac9ce7e47d6 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,54 +1,59 @@ <script> - import loadingIcon from '~/vue_shared/components/loading_icon.vue'; - import stageColumnComponent from './stage_column_component.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import StageColumnComponent from './stage_column_component.vue'; - export default { - components: { - stageColumnComponent, - loadingIcon, - }, +export default { + components: { + StageColumnComponent, + LoadingIcon, + }, - props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, + props: { + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, + actionDisabled: { + type: String, + required: false, + default: null, }, + }, - computed: { - graph() { - return this.pipeline.details && this.pipeline.details.stages; - }, + computed: { + graph() { + return this.pipeline.details && this.pipeline.details.stages; }, + }, - methods: { - capitalizeStageName(name) { - return name.charAt(0).toUpperCase() + name.slice(1); - }, + methods: { + capitalizeStageName(name) { + return name.charAt(0).toUpperCase() + name.slice(1); + }, - isFirstColumn(index) { - return index === 0; - }, + isFirstColumn(index) { + return index === 0; + }, - stageConnectorClass(index, stage) { - let className; + stageConnectorClass(index, stage) { + let className; - // If it's the first stage column and only has one job - if (index === 0 && stage.groups.length === 1) { - className = 'no-margin'; - } else if (index > 0) { - // If it is not the first column - className = 'left-margin'; - } + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } - return className; - }, + return className; }, - }; + }, +}; </script> <template> <div class="build-content middle-block js-pipeline-graph"> @@ -70,6 +75,7 @@ :key="stage.name" :stage-connector-class="stageConnectorClass(index, stage)" :is-first-column="isFirstColumn(index)" + :action-disabled="actionDisabled" /> </ul> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index 9b136573135..c6e5ae6df41 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -1,95 +1,102 @@ <script> - import actionComponent from './action_component.vue'; - import dropdownActionComponent from './dropdown_action_component.vue'; - import jobNameComponent from './job_name_component.vue'; - import tooltip from '../../../vue_shared/directives/tooltip'; - - /** - * Renders the badge for the pipeline graph and the job's dropdown. - * - * The following object should be provided as `job`: - * - * { - * "id": 4256, - * "name": "test", - * "status": { - * "icon": "icon_status_success", - * "text": "passed", - * "label": "passed", - * "group": "success", - * "details_path": "/root/ci-mock/builds/4256", - * "action": { - * "icon": "retry", - * "title": "Retry", - * "path": "/root/ci-mock/builds/4256/retry", - * "method": "post" - * } - * } - * } - */ - - export default { - components: { - actionComponent, - dropdownActionComponent, - jobNameComponent, +import ActionComponent from './action_component.vue'; +import DropdownActionComponent from './dropdown_action_component.vue'; +import JobNameComponent from './job_name_component.vue'; +import tooltip from '../../../vue_shared/directives/tooltip'; + +/** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "tooltip": "passed", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + +export default { + components: { + ActionComponent, + DropdownActionComponent, + JobNameComponent, + }, + + directives: { + tooltip, + }, + props: { + job: { + type: Object, + required: true, }, - directives: { - tooltip, + cssClassJobName: { + type: String, + required: false, + default: '', }, - props: { - job: { - type: Object, - required: true, - }, - - cssClassJobName: { - type: String, - required: false, - default: '', - }, - - isDropdown: { - type: Boolean, - required: false, - default: false, - }, + + isDropdown: { + type: Boolean, + required: false, + default: false, + }, + + actionDisabled: { + type: String, + required: false, + default: null, + }, + }, + + computed: { + status() { + return this.job && this.job.status ? this.job.status : {}; + }, + + tooltipText() { + const textBuilder = []; + + if (this.job.name) { + textBuilder.push(this.job.name); + } + + if (this.job.name && this.status.tooltip) { + textBuilder.push('-'); + } + + if (this.status.tooltip) { + textBuilder.push(`${this.job.status.tooltip}`); + } + + return textBuilder.join(' '); }, - computed: { - status() { - return this.job && this.job.status ? this.job.status : {}; - }, - - tooltipText() { - const textBuilder = []; - - if (this.job.name) { - textBuilder.push(this.job.name); - } - - if (this.job.name && this.status.label) { - textBuilder.push('-'); - } - - if (this.status.label) { - textBuilder.push(`${this.job.status.label}`); - } - - return textBuilder.join(' '); - }, - - /** - * Verifies if the provided job has an action path - * - * @return {Boolean} - */ - hasAction() { - return this.job.status && this.job.status.action && this.job.status.action.path; - }, + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; }, - }; + }, +}; </script> <template> <div class="ci-job-component"> @@ -100,6 +107,7 @@ :title="tooltipText" :class="cssClassJobName" data-container="body" + data-html="true" class="js-pipeline-graph-job-link" > @@ -115,6 +123,7 @@ class="js-job-component-tooltip" :title="tooltipText" :class="cssClassJobName" + data-html="true" data-container="body" > @@ -129,7 +138,7 @@ :tooltip-text="status.action.title" :link="status.action.path" :action-icon="status.action.icon" - :action-method="status.action.method" + :button-disabled="actionDisabled" /> <dropdown-action-component diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 7adcf4017b8..f6e6569e15b 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,50 +1,55 @@ <script> - import jobComponent from './job_component.vue'; - import dropdownJobComponent from './dropdown_job_component.vue'; +import JobComponent from './job_component.vue'; +import DropdownJobComponent from './dropdown_job_component.vue'; - export default { - components: { - jobComponent, - dropdownJobComponent, +export default { + components: { + JobComponent, + DropdownJobComponent, + }, + props: { + title: { + type: String, + required: true, }, - props: { - title: { - type: String, - required: true, - }, - jobs: { - type: Array, - required: true, - }, + jobs: { + type: Array, + required: true, + }, - isFirstColumn: { - type: Boolean, - required: false, - default: false, - }, + isFirstColumn: { + type: Boolean, + required: false, + default: false, + }, - stageConnectorClass: { - type: String, - required: false, - default: '', - }, + stageConnectorClass: { + type: String, + required: false, + default: '', + }, + actionDisabled: { + type: String, + required: false, + default: null, }, + }, - methods: { - firstJob(list) { - return list[0]; - }, + methods: { + firstJob(list) { + return list[0]; + }, - jobId(job) { - return `ci-badge-${job.name}`; - }, + jobId(job) { + return `ci-badge-${job.name}`; + }, - buildConnnectorClass(index) { - return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; - }, + buildConnnectorClass(index) { + return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; }, - }; + }, +}; </script> <template> <li @@ -69,6 +74,7 @@ v-if="job.size === 1" :job="job" css-class-job-name="build-content" + :action-disabled="actionDisabled" /> <dropdown-job-component diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index e0a7284124d..497a09cec65 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -7,10 +7,7 @@ import TablePagination from '../../vue_shared/components/table_pagination.vue'; import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; import NavigationControls from './nav_controls.vue'; - import { - getParameterByName, - parseQueryStringIntoObject, - } from '../../lib/utils/common_utils'; + import { getParameterByName } from '../../lib/utils/common_utils'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; export default { @@ -19,10 +16,7 @@ NavigationTabs, NavigationControls, }, - mixins: [ - pipelinesMixin, - CIPaginationMixin, - ], + mixins: [pipelinesMixin, CIPaginationMixin], props: { store: { type: Object, @@ -147,25 +141,26 @@ */ shouldRenderTabs() { const { stateMap } = this.$options; - return this.hasMadeRequest && - [ - stateMap.loading, - stateMap.tableList, - stateMap.error, - stateMap.emptyTab, - ].includes(this.stateToRender); + return ( + this.hasMadeRequest && + [stateMap.loading, stateMap.tableList, stateMap.error, stateMap.emptyTab].includes( + this.stateToRender, + ) + ); }, shouldRenderButtons() { - return (this.newPipelinePath || - this.resetCachePath || - this.ciLintPath) && this.shouldRenderTabs; + return ( + (this.newPipelinePath || this.resetCachePath || this.ciLintPath) && this.shouldRenderTabs + ); }, shouldRenderPagination() { - return !this.isLoading && + return ( + !this.isLoading && this.state.pipelines.length && - this.state.pageInfo.total > this.state.pageInfo.perPage; + this.state.pageInfo.total > this.state.pageInfo.perPage + ); }, emptyTabMessage() { @@ -229,15 +224,13 @@ }, methods: { successCallback(resp) { - return resp.json().then((response) => { - // Because we are polling & the user is interacting verify if the response received - // matches the last request made - if (_.isEqual(parseQueryStringIntoObject(resp.url.split('?')[1]), this.requestData)) { - this.store.storeCount(response.count); - this.store.storePagination(resp.headers); - this.setCommonData(response.pipelines); - } - }); + // Because we are polling & the user is interacting verify if the response received + // matches the last request made + if (_.isEqual(resp.config.params, this.requestData)) { + this.store.storeCount(resp.data.count); + this.store.storePagination(resp.headers); + this.setCommonData(resp.data.pipelines); + } }, /** * Handles URL and query parameter changes. @@ -251,8 +244,9 @@ this.updateInternalState(parameters); // fetch new data - return this.service.getPipelines(this.requestData) - .then((response) => { + return this.service + .getPipelines(this.requestData) + .then(response => { this.isLoading = false; this.successCallback(response); @@ -271,13 +265,11 @@ handleResetRunnersCache(endpoint) { this.isResetCacheButtonLoading = true; - this.service.postAction(endpoint) + this.service + .postAction(endpoint) .then(() => { this.isResetCacheButtonLoading = false; - createFlash( - s__('Pipelines|Project cache successfully reset.'), - 'notice', - ); + createFlash(s__('Pipelines|Project cache successfully reset.'), 'notice'); }) .catch(() => { this.isResetCacheButtonLoading = false; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 6b26708148c..900eb7855f4 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -25,13 +25,36 @@ export default () => { data() { return { mediator, + actionDisabled: null, }; }, + created() { + eventHub.$on('graphAction', this.postAction); + }, + beforeDestroy() { + eventHub.$off('graphAction', this.postAction); + }, + methods: { + postAction(action) { + this.actionDisabled = action; + + this.mediator.service.postAction(action) + .then(() => { + this.mediator.refreshPipeline(); + this.actionDisabled = null; + }) + .catch(() => { + this.actionDisabled = null; + Flash(__('An error occurred while making the request.')); + }); + }, + }, render(createElement) { return createElement('pipeline-graph', { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, + actionDisabled: this.actionDisabled, }, }); }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index 10f238fe73b..621969cd622 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -52,8 +52,11 @@ export default class pipelinesMediator { } refreshPipeline() { - this.service.getPipeline() + this.poll.stop(); + + return this.service.getPipeline() .then(response => this.successCallback(response)) - .catch(() => this.errorCallback()); + .catch(() => this.errorCallback()) + .finally(() => this.poll.restart()); } } diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 47736fc5f42..001286f5d52 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -1,35 +1,27 @@ -/* eslint-disable class-methods-use-this */ -import Vue from 'vue'; -import VueResource from 'vue-resource'; -import '../../vue_shared/vue_resource_interceptor'; - -Vue.use(VueResource); +import axios from '../../lib/utils/axios_utils'; export default class PipelinesService { - /** - * Commits and merge request endpoints need to be requested with `.json`. - * - * The url provided to request the pipelines in the new merge request - * page already has `.json`. - * - * @param {String} root - */ + * Commits and merge request endpoints need to be requested with `.json`. + * + * The url provided to request the pipelines in the new merge request + * page already has `.json`. + * + * @param {String} root + */ constructor(root) { - let endpoint; - if (root.indexOf('.json') === -1) { - endpoint = `${root}.json`; + this.endpoint = `${root}.json`; } else { - endpoint = root; + this.endpoint = root; } - - this.pipelines = Vue.resource(endpoint); } getPipelines(data = {}) { const { scope, page } = data; - return this.pipelines.get({ scope, page }); + return axios.get(this.endpoint, { + params: { scope, page }, + }); } /** @@ -38,7 +30,8 @@ export default class PipelinesService { * @param {String} endpoint * @return {Promise} */ + // eslint-disable-next-line class-methods-use-this postAction(endpoint) { - return Vue.http.post(`${endpoint}.json`); + return axios.post(`${endpoint}.json`); } } diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue new file mode 100644 index 00000000000..e5de3f69b01 --- /dev/null +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -0,0 +1,121 @@ +<script> +import _ from 'underscore'; +import axios from '~/lib/utils/axios_utils'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import { s__, sprintf } from '~/locale'; +import Flash from '~/flash'; + +export default { + components: { + GlModal, + }, + props: { + actionUrl: { + type: String, + required: true, + }, + rootUrl: { + type: String, + required: true, + }, + initialUsername: { + type: String, + required: true, + }, + }, + data() { + return { + isRequestPending: false, + username: this.initialUsername, + newUsername: this.initialUsername, + }; + }, + computed: { + path() { + return sprintf(s__('Profiles|Current path: %{path}'), { + path: `${this.rootUrl}${this.username}`, + }); + }, + modalText() { + return sprintf( + s__(`Profiles| +You are going to change the username %{currentUsernameBold} to %{newUsernameBold}. +Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group. +Please update your Git repository remotes as soon as possible.`), + { + currentUsernameBold: `<strong>${_.escape(this.username)}</strong>`, + newUsernameBold: `<strong>${_.escape(this.newUsername)}</strong>`, + currentUsername: _.escape(this.username), + newUsername: _.escape(this.newUsername), + }, + false, + ); + }, + }, + methods: { + onConfirm() { + this.isRequestPending = true; + const username = this.newUsername; + const putData = { + user: { + username, + }, + }; + + return axios + .put(this.actionUrl, putData) + .then(result => { + Flash(result.data.message, 'notice'); + this.username = username; + this.isRequestPending = false; + }) + .catch(error => { + Flash(error.response.data.message); + this.isRequestPending = false; + throw error; + }); + }, + }, + modalId: 'username-change-confirmation-modal', + inputId: 'username-change-input', + buttonText: s__('Profiles|Update username'), +}; +</script> +<template> + <div> + <div class="form-group"> + <label :for="$options.inputId">{{ s__('Profiles|Path') }}</label> + <div class="input-group"> + <div class="input-group-addon">{{ rootUrl }}</div> + <input + :id="$options.inputId" + class="form-control" + required="required" + v-model="newUsername" + :disabled="isRequestPending" + /> + </div> + <p class="help-block"> + {{ path }} + </p> + </div> + <button + :data-target="`#${$options.modalId}`" + class="btn btn-warning" + type="button" + data-toggle="modal" + :disabled="isRequestPending || newUsername === username" + > + {{ $options.buttonText }} + </button> + <gl-modal + :id="$options.modalId" + :header-title-text="s__('Profiles|Change username') + '?'" + footer-primary-button-variant="warning" + :footer-primary-button-text="$options.buttonText" + @submit="onConfirm" + > + <span v-html="modalText"></span> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js index 84049a1f0b7..59c13e1a042 100644 --- a/app/assets/javascripts/profile/account/index.js +++ b/app/assets/javascripts/profile/account/index.js @@ -1,10 +1,25 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import UpdateUsername from './components/update_username.vue'; import deleteAccountModal from './components/delete_account_modal.vue'; export default () => { Vue.use(Translate); + const updateUsernameElement = document.getElementById('update-username'); + // eslint-disable-next-line no-new + new Vue({ + el: updateUsernameElement, + components: { + UpdateUsername, + }, + render(createElement) { + return createElement('update-username', { + props: { ...updateUsernameElement.dataset }, + }); + }, + }); + const deleteAccountButton = document.getElementById('delete-account-button'); const deleteAccountModalEl = document.getElementById('delete-account-modal'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 7dd3e9858c6..2da022fde63 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -233,21 +233,21 @@ export default class SearchAutocomplete { const issueItems = [ { text: 'Issues assigned to me', - url: `${issuesPath}/?assignee_username=${userName}`, + url: `${issuesPath}/?assignee_id=${userId}`, }, { text: "Issues I've created", - url: `${issuesPath}/?author_username=${userName}`, + url: `${issuesPath}/?author_id=${userId}`, }, ]; const mergeRequestItems = [ { text: 'Merge requests assigned to me', - url: `${mrPath}/?assignee_username=${userName}`, + url: `${mrPath}/?assignee_id=${userId}`, }, { text: "Merge requests I've created", - url: `${mrPath}/?author_username=${userName}`, + url: `${mrPath}/?author_id=${userId}`, }, ]; diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 8a86c409b62..ceb02309959 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,59 +1,73 @@ <script> - import Flash from '../../../flash'; - import editForm from './edit_form.vue'; - import Icon from '../../../vue_shared/components/icon.vue'; - import { __ } from '../../../locale'; +import Flash from '../../../flash'; +import editForm from './edit_form.vue'; +import Icon from '../../../vue_shared/components/icon.vue'; +import { __ } from '../../../locale'; +import eventHub from '../../event_hub'; - export default { - components: { - editForm, - Icon, +export default { + components: { + editForm, + Icon, + }, + props: { + isConfidential: { + required: true, + type: Boolean, }, - props: { - isConfidential: { - required: true, - type: Boolean, - }, - isEditable: { - required: true, - type: Boolean, - }, - service: { - required: true, - type: Object, - }, + isEditable: { + required: true, + type: Boolean, }, - data() { - return { - edit: false, - }; + service: { + required: true, + type: Object, }, - computed: { - confidentialityIcon() { - return this.isConfidential ? 'eye-slash' : 'eye'; - }, + }, + data() { + return { + edit: false, + }; + }, + computed: { + confidentialityIcon() { + return this.isConfidential ? 'eye-slash' : 'eye'; }, - methods: { - toggleForm() { - this.edit = !this.edit; - }, - updateConfidentialAttribute(confidential) { - this.service.update('issue', { confidential }) - .then(() => location.reload()) - .catch(() => { - Flash(__('Something went wrong trying to change the confidentiality of this issue')); - }); - }, + }, + created() { + eventHub.$on('closeConfidentialityForm', this.toggleForm); + }, + beforeDestroy() { + eventHub.$off('closeConfidentialityForm', this.toggleForm); + }, + methods: { + toggleForm() { + this.edit = !this.edit; }, - }; + updateConfidentialAttribute(confidential) { + this.service + .update('issue', { confidential }) + .then(() => location.reload()) + .catch(() => { + Flash( + __( + 'Something went wrong trying to change the confidentiality of this issue', + ), + ); + }); + }, + }, +}; </script> <template> <div class="block issuable-sidebar-item confidentiality"> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + @click="toggleForm" + > <icon :name="confidentialityIcon" - :size="16" aria-hidden="true" /> </div> @@ -71,7 +85,6 @@ <div class="value sidebar-item-value hide-collapsed"> <editForm v-if="edit" - :toggle-form="toggleForm" :is-confidential="isConfidential" :update-confidential-attribute="updateConfidentialAttribute" /> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index c569843b05f..3783f71a848 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -1,34 +1,34 @@ <script> - import editFormButtons from './edit_form_buttons.vue'; - import { s__ } from '../../../locale'; +import editFormButtons from './edit_form_buttons.vue'; +import { s__ } from '../../../locale'; - export default { - components: { - editFormButtons, +export default { + components: { + editFormButtons, + }, + props: { + isConfidential: { + required: true, + type: Boolean, }, - props: { - isConfidential: { - required: true, - type: Boolean, - }, - toggleForm: { - required: true, - type: Function, - }, - updateConfidentialAttribute: { - required: true, - type: Function, - }, + updateConfidentialAttribute: { + required: true, + type: Function, }, - computed: { - confidentialityOnWarning() { - return s__('confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.'); - }, - confidentialityOffWarning() { - return s__('confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.'); - }, + }, + computed: { + confidentialityOnWarning() { + return s__( + 'confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.', + ); }, - }; + confidentialityOffWarning() { + return s__( + 'confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.', + ); + }, + }, +}; </script> <template> @@ -45,7 +45,6 @@ </p> <edit-form-buttons :is-confidential="isConfidential" - :toggle-form="toggleForm" :update-confidential-attribute="updateConfidentialAttribute" /> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 49d5dfeea1a..38b1ddbfd5b 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,14 +1,13 @@ <script> +import $ from 'jquery'; +import eventHub from '../../event_hub'; + export default { props: { isConfidential: { required: true, type: Boolean, }, - toggleForm: { - required: true, - type: Function, - }, updateConfidentialAttribute: { required: true, type: Function, @@ -22,6 +21,16 @@ export default { return !this.isConfidential; }, }, + methods: { + closeForm() { + eventHub.$emit('closeConfidentialityForm'); + $(this.$el).trigger('hidden.gl.dropdown'); + }, + submitForm() { + this.closeForm(); + this.updateConfidentialAttribute(this.updateConfidentialBool); + }, + }, }; </script> @@ -30,14 +39,14 @@ export default { <button type="button" class="btn btn-default append-right-10" - @click="toggleForm" + @click="closeForm" > {{ __('Cancel') }} </button> <button type="button" class="btn btn-close" - @click.prevent="updateConfidentialAttribute(updateConfidentialBool)" + @click.prevent="submitForm" > {{ toggleButtonText }} </button> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index bc32e974bc3..e1e4715826a 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -1,40 +1,43 @@ <script> - import editFormButtons from './edit_form_buttons.vue'; - import issuableMixin from '../../../vue_shared/mixins/issuable'; - import { __, sprintf } from '../../../locale'; +import editFormButtons from './edit_form_buttons.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; +import { __, sprintf } from '../../../locale'; - export default { - components: { - editFormButtons, +export default { + components: { + editFormButtons, + }, + mixins: [issuableMixin], + props: { + isLocked: { + required: true, + type: Boolean, }, - mixins: [ - issuableMixin, - ], - props: { - isLocked: { - required: true, - type: Boolean, - }, - toggleForm: { - required: true, - type: Function, - }, - - updateLockedAttribute: { - required: true, - type: Function, - }, + updateLockedAttribute: { + required: true, + type: Function, + }, + }, + computed: { + lockWarning() { + return sprintf( + __( + 'Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.', + ), + { issuableDisplayName: this.issuableDisplayName }, + ); }, - computed: { - lockWarning() { - return sprintf(__('Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); - }, - unlockWarning() { - return sprintf(__('Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); - }, + unlockWarning() { + return sprintf( + __( + 'Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.', + ), + { issuableDisplayName: this.issuableDisplayName }, + ); }, - }; + }, +}; </script> <template> @@ -54,7 +57,6 @@ <edit-form-buttons :is-locked="isLocked" - :toggle-form="toggleForm" :update-locked-attribute="updateLockedAttribute" /> </div> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index c3a553a7605..5e7b8f9698f 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,4 +1,7 @@ <script> +import $ from 'jquery'; +import eventHub from '../../event_hub'; + export default { props: { isLocked: { @@ -6,11 +9,6 @@ export default { type: Boolean, }, - toggleForm: { - required: true, - type: Function, - }, - updateLockedAttribute: { required: true, type: Function, @@ -26,6 +24,17 @@ export default { return !this.isLocked; }, }, + + methods: { + closeForm() { + eventHub.$emit('closeLockForm'); + $(this.$el).trigger('hidden.gl.dropdown'); + }, + submitForm() { + this.closeForm(); + this.updateLockedAttribute(this.toggleLock); + }, + }, }; </script> @@ -34,7 +43,7 @@ export default { <button type="button" class="btn btn-default append-right-10" - @click="toggleForm" + @click="closeForm" > {{ __('Cancel') }} </button> @@ -42,7 +51,7 @@ export default { <button type="button" class="btn btn-close" - @click.prevent="updateLockedAttribute(toggleLock)" + @click.prevent="submitForm" > {{ buttonText }} </button> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index 0686910fc7e..e4893451af3 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -1,70 +1,93 @@ <script> - import Flash from '~/flash'; - import editForm from './edit_form.vue'; - import issuableMixin from '../../../vue_shared/mixins/issuable'; - import Icon from '../../../vue_shared/components/icon.vue'; +import Flash from '~/flash'; +import editForm from './edit_form.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; +import Icon from '../../../vue_shared/components/icon.vue'; +import eventHub from '../../event_hub'; - export default { - components: { - editForm, - Icon, - }, - mixins: [ - issuableMixin, - ], +export default { + components: { + editForm, + Icon, + }, + mixins: [issuableMixin], - props: { - isLocked: { - required: true, - type: Boolean, - }, + props: { + isLocked: { + required: true, + type: Boolean, + }, - isEditable: { - required: true, - type: Boolean, - }, + isEditable: { + required: true, + type: Boolean, + }, - mediator: { - required: true, - type: Object, - validator(mediatorObject) { - return mediatorObject.service && mediatorObject.service.update && mediatorObject.store; - }, + mediator: { + required: true, + type: Object, + validator(mediatorObject) { + return ( + mediatorObject.service && + mediatorObject.service.update && + mediatorObject.store + ); }, }, + }, - computed: { - lockIcon() { - return this.isLocked ? 'lock' : 'lock-open'; - }, + computed: { + lockIcon() { + return this.isLocked ? 'lock' : 'lock-open'; + }, - isLockDialogOpen() { - return this.mediator.store.isLockDialogOpen; - }, + isLockDialogOpen() { + return this.mediator.store.isLockDialogOpen; }, + }, - methods: { - toggleForm() { - this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; - }, + created() { + eventHub.$on('closeLockForm', this.toggleForm); + }, + + beforeDestroy() { + eventHub.$off('closeLockForm', this.toggleForm); + }, - updateLockedAttribute(locked) { - this.mediator.service.update(this.issuableType, { + methods: { + toggleForm() { + this.mediator.store.isLockDialogOpen = !this.mediator.store + .isLockDialogOpen; + }, + + updateLockedAttribute(locked) { + this.mediator.service + .update(this.issuableType, { discussion_locked: locked, }) .then(() => location.reload()) - .catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName}`))); - }, + .catch(() => + Flash( + this.__( + `Something went wrong trying to change the locked state of this ${ + this.issuableDisplayName + }`, + ), + ), + ); }, - }; + }, +}; </script> <template> <div class="block issuable-sidebar-item lock"> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + @click="toggleForm" + > <icon :name="lockIcon" - :size="16" aria-hidden="true" class="sidebar-item-icon is-active" /> @@ -85,7 +108,6 @@ <div class="value sidebar-item-value hide-collapsed"> <edit-form v-if="isLockDialogOpen" - :toggle-form="toggleForm" :is-locked="isLocked" :update-locked-attribute="updateLockedAttribute" :issuable-type="issuableType" diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index f3b961eb109..520a0b3f424 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,6 +5,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; +import ModalStore from './boards/stores/modal_store'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; @@ -441,7 +442,7 @@ function UsersSelect(currentUser, els, options = {}) { return; } if ($el.closest('.add-issues-modal').length) { - gl.issueBoards.ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; + ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; } else if (handleClick) { e.preventDefault(); handleClick(user, isMarking); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 3d886e7d628..18ee4c62bf1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -1,53 +1,57 @@ <script> - import tooltip from '~/vue_shared/directives/tooltip'; - import { n__ } from '~/locale'; - import icon from '~/vue_shared/components/icon.vue'; - import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { n__ } from '~/locale'; +import { webIDEUrl } from '~/lib/utils/url_utility'; +import icon from '~/vue_shared/components/icon.vue'; +import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; - export default { - name: 'MRWidgetHeader', - directives: { - tooltip, +export default { + name: 'MRWidgetHeader', + directives: { + tooltip, + }, + components: { + icon, + clipboardButton, + }, + props: { + mr: { + type: Object, + required: true, }, - components: { - icon, - clipboardButton, + }, + computed: { + shouldShowCommitsBehindText() { + return this.mr.divergedCommitsCount > 0; }, - props: { - mr: { - type: Object, - required: true, - }, + commitsText() { + return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount); }, - computed: { - shouldShowCommitsBehindText() { - return this.mr.divergedCommitsCount > 0; - }, - commitsText() { - return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount); - }, - branchNameClipboardData() { - // This supports code in app/assets/javascripts/copy_to_clipboard.js that - // works around ClipboardJS limitations to allow the context-specific - // copy/pasting of plain text or GFM. - return JSON.stringify({ - text: this.mr.sourceBranch, - gfm: `\`${this.mr.sourceBranch}\``, - }); - }, - isSourceBranchLong() { - return this.isBranchTitleLong(this.mr.sourceBranch); - }, - isTargetBranchLong() { - return this.isBranchTitleLong(this.mr.targetBranch); - }, + branchNameClipboardData() { + // This supports code in app/assets/javascripts/copy_to_clipboard.js that + // works around ClipboardJS limitations to allow the context-specific + // copy/pasting of plain text or GFM. + return JSON.stringify({ + text: this.mr.sourceBranch, + gfm: `\`${this.mr.sourceBranch}\``, + }); }, - methods: { - isBranchTitleLong(branchTitle) { - return branchTitle.length > 32; - }, + isSourceBranchLong() { + return this.isBranchTitleLong(this.mr.sourceBranch); }, - }; + isTargetBranchLong() { + return this.isBranchTitleLong(this.mr.targetBranch); + }, + webIdePath() { + return webIDEUrl(this.mr.statusPath.replace('.json', '')); + }, + }, + methods: { + isBranchTitleLong(branchTitle) { + return branchTitle.length > 32; + }, + }, +}; </script> <template> <div class="mr-source-target"> @@ -96,6 +100,13 @@ </div> <div v-if="mr.isOpen"> + <a + v-if="!mr.sourceBranchRemoved" + :href="webIdePath" + class="btn btn-sm btn-default inline js-web-ide" + > + {{ s__("mrWidget|Web IDE") }} + </a> <button data-target="#modal_merge_info" data-toggle="modal" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 54a98abf860..48dff8c4916 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -1,56 +1,61 @@ <script> - /* eslint-disable vue/require-default-prop */ - import pipelineStage from '~/pipelines/components/stage.vue'; - import ciIcon from '~/vue_shared/components/ci_icon.vue'; - import icon from '~/vue_shared/components/icon.vue'; +/* eslint-disable vue/require-default-prop */ +import PipelineStage from '~/pipelines/components/stage.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; - export default { - name: 'MRWidgetPipeline', - components: { - pipelineStage, - ciIcon, - icon, +export default { + name: 'MRWidgetPipeline', + components: { + PipelineStage, + CiIcon, + Icon, + }, + props: { + pipeline: { + type: Object, + required: true, }, - props: { - pipeline: { - type: Object, - required: true, - }, - // This prop needs to be camelCase, html attributes are case insensive - // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case - hasCi: { - type: Boolean, - required: false, - }, - ciStatus: { - type: String, - required: false, - }, + // This prop needs to be camelCase, html attributes are case insensive + // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case + hasCi: { + type: Boolean, + required: false, }, - computed: { - hasPipeline() { - return this.pipeline && Object.keys(this.pipeline).length > 0; - }, - hasCIError() { - return this.hasCi && !this.ciStatus; - }, - status() { - return this.pipeline.details && - this.pipeline.details.status ? this.pipeline.details.status : {}; - }, - hasStages() { - return this.pipeline.details && - this.pipeline.details.stages && - this.pipeline.details.stages.length; - }, + ciStatus: { + type: String, + required: false, }, - }; + }, + computed: { + hasPipeline() { + return this.pipeline && Object.keys(this.pipeline).length > 0; + }, + hasCIError() { + return this.hasCi && !this.ciStatus; + }, + status() { + return this.pipeline.details && this.pipeline.details.status + ? this.pipeline.details.status + : {}; + }, + hasStages() { + return ( + this.pipeline.details && this.pipeline.details.stages && this.pipeline.details.stages.length + ); + }, + hasCommitInfo() { + return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; + }, + }, +}; </script> <template> <div v-if="hasPipeline || hasCIError" - class="mr-widget-heading"> + class="mr-widget-heading" + > <div class="ci-widget media"> <template v-if="hasCIError"> <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10"> @@ -77,13 +82,17 @@ #{{ pipeline.id }} </a> - {{ pipeline.details.status.label }} for + {{ pipeline.details.status.label }} - <a - :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link" - > - {{ pipeline.commit.short_id }}</a>. + <template v-if="hasCommitInfo"> + for + + <a + :href="pipeline.commit.commit_path" + class="commit-sha js-commit-link" + > + {{ pipeline.commit.short_id }}</a>. + </template> <span class="mr-widget-pipeline-graph"> <span diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue new file mode 100644 index 00000000000..4155e1bab9c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -0,0 +1,58 @@ +<script> +import { viewerInformationForPath } from './lib/viewer_utils'; +import MarkdownViewer from './viewers/markdown_viewer.vue'; +import ImageViewer from './viewers/image_viewer.vue'; +import DownloadViewer from './viewers/download_viewer.vue'; + +export default { + props: { + content: { + type: String, + default: '', + }, + path: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: false, + default: 0, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + viewer() { + if (!this.path) return null; + + const previewInfo = viewerInformationForPath(this.path); + if (!previewInfo) return DownloadViewer; + + switch (previewInfo.id) { + case 'markdown': + return MarkdownViewer; + case 'image': + return ImageViewer; + default: + return DownloadViewer; + } + }, + }, +}; +</script> + +<template> + <div class="preview-container"> + <component + :is="viewer" + :path="path" + :file-size="fileSize" + :project-path="projectPath" + :content="content" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js new file mode 100644 index 00000000000..f01a51da0b3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js @@ -0,0 +1,32 @@ +const viewers = { + image: { + id: 'image', + }, + markdown: { + id: 'markdown', + previewTitle: 'Preview Markdown', + }, +}; + +const fileNameViewers = {}; +const fileExtensionViewers = { + jpg: 'image', + jpeg: 'image', + gif: 'image', + png: 'image', + bmp: 'image', + ico: 'image', + md: 'markdown', + markdown: 'markdown', +}; + +export function viewerInformationForPath(path) { + if (!path) return null; + const name = path.split('/').pop(); + const viewerName = + fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || ''; + + return viewers[viewerName]; +} + +export default viewers; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue new file mode 100644 index 00000000000..395a71acccf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -0,0 +1,52 @@ +<script> +import Icon from '../../icon.vue'; +import { numberToHumanSize } from '../../../../lib/utils/number_utils'; + +export default { + components: { + Icon, + }, + props: { + path: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + fileSizeReadable() { + return numberToHumanSize(this.fileSize); + }, + fileName() { + return this.path.split('/').pop(); + }, + }, +}; +</script> + +<template> + <div class="file-container"> + <div class="file-content"> + <p class="prepend-top-10 file-info"> + {{ fileName }} ({{ fileSizeReadable }}) + </p> + <a + :href="path" + class="btn btn-default" + rel="nofollow" + download + target="_blank"> + <icon + name="download" + css-classes="pull-left append-right-8" + :size="16" + /> + {{ __('Download') }} + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue new file mode 100644 index 00000000000..a5999f909ca --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -0,0 +1,68 @@ +<script> +import { numberToHumanSize } from '../../../../lib/utils/number_utils'; + +export default { + props: { + path: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: false, + default: 0, + }, + }, + data() { + return { + width: 0, + height: 0, + isZoomable: false, + isZoomed: false, + }; + }, + computed: { + fileSizeReadable() { + return numberToHumanSize(this.fileSize); + }, + }, + methods: { + onImgLoad() { + const contentImg = this.$refs.contentImg; + this.isZoomable = + contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height; + + this.width = contentImg.naturalWidth; + this.height = contentImg.naturalHeight; + }, + onImgClick() { + if (this.isZoomable) this.isZoomed = !this.isZoomed; + }, + }, +}; +</script> + +<template> + <div class="file-container"> + <div class="file-content image_file"> + <img + ref="contentImg" + :class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }" + :src="path" + :alt="path" + @load="onImgLoad" + @click="onImgClick"/> + <p class="file-info prepend-top-10"> + <template v-if="fileSize>0"> + {{ fileSizeReadable }} + </template> + <template v-if="fileSize>0 && width && height"> + - + </template> + <template v-if="width && height"> + {{ width }} x {{ height }} + </template> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue new file mode 100644 index 00000000000..09e0094054d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -0,0 +1,90 @@ +<script> +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import $ from 'jquery'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + +const CancelToken = axios.CancelToken; +let axiosSource; + +export default { + components: { + SkeletonLoadingContainer, + }, + props: { + content: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + previewContent: null, + isLoading: false, + }; + }, + watch: { + content() { + this.previewContent = null; + }, + }, + created() { + axiosSource = CancelToken.source(); + this.fetchMarkdownPreview(); + }, + updated() { + this.fetchMarkdownPreview(); + }, + destroyed() { + if (this.isLoading) axiosSource.cancel('Cancelling Preview'); + }, + methods: { + fetchMarkdownPreview() { + if (this.content && this.previewContent === null) { + this.isLoading = true; + const postBody = { + text: this.content, + }; + const postOptions = { + cancelToken: axiosSource.token, + }; + + axios + .post( + `${gon.relative_url_root}/${this.projectPath}/preview_markdown`, + postBody, + postOptions, + ) + .then(({ data }) => { + this.previewContent = data.body; + this.isLoading = false; + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); + }) + .catch(() => { + this.previewContent = __('An error occurred while fetching markdown preview'); + this.isLoading = false; + }); + } + }, + }, +}; +</script> + +<template> + <div + ref="markdown-preview" + class="md md-previewer"> + <skeleton-loading-container v-if="isLoading" /> + <div + v-else + v-html="previewContent"> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue index 67c9181c7b1..f28e5e2715d 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -1,47 +1,42 @@ <script> - const buttonVariants = [ - 'danger', - 'primary', - 'success', - 'warning', - ]; +const buttonVariants = ['danger', 'primary', 'success', 'warning']; - export default { - name: 'GlModal', +export default { + name: 'GlModal', - props: { - id: { - type: String, - required: false, - default: null, - }, - headerTitleText: { - type: String, - required: false, - default: '', - }, - footerPrimaryButtonVariant: { - type: String, - required: false, - default: 'primary', - validator: value => buttonVariants.indexOf(value) !== -1, - }, - footerPrimaryButtonText: { - type: String, - required: false, - default: '', - }, + props: { + id: { + type: String, + required: false, + default: null, }, + headerTitleText: { + type: String, + required: false, + default: '', + }, + footerPrimaryButtonVariant: { + type: String, + required: false, + default: 'primary', + validator: value => buttonVariants.includes(value), + }, + footerPrimaryButtonText: { + type: String, + required: false, + default: '', + }, + }, - methods: { - emitCancel(event) { - this.$emit('cancel', event); - }, - emitSubmit(event) { - this.$emit('submit', event); - }, + methods: { + emitCancel(event) { + this.$emit('cancel', event); + }, + emitSubmit(event) { + this.$emit('submit', event); }, - }; + }, +}; </script> <template> @@ -60,7 +55,7 @@ <slot name="header"> <button type="button" - class="close" + class="close js-modal-close-action" data-dismiss="modal" :aria-label="s__('Modal|Close')" @click="emitCancel($event)" @@ -83,7 +78,7 @@ <slot name="footer"> <button type="button" - class="btn" + class="btn js-modal-cancel-action" data-dismiss="modal" @click="emitCancel($event)" > @@ -91,7 +86,7 @@ </button> <button type="button" - class="btn" + class="btn js-modal-primary-action" :class="`btn-${footerPrimaryButtonVariant}`" data-dismiss="modal" @click="emitSubmit($event)" diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index df1cafc9f8e..62a0fba3da3 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -20,7 +20,7 @@ width: 100%; } - $image-widths: 80 250 306 394 430; + $image-widths: 80 130 250 306 394 430; @each $width in $image-widths { &.svg-#{$width} { img, diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 7e829826eba..f1a8a46dda4 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -24,6 +24,10 @@ color: $list-text-disabled-color; } + &:not(.ui-sort-disabled):hover { + background: $row-hover; + } + &.unstyled { &:hover { background: none; @@ -34,14 +38,15 @@ background-color: $list-warning-row-bg; border-color: $list-warning-row-border; color: $list-warning-row-color; - } - &.smoke { background-color: $gray-light; } + &:hover { + background: $list-warning-row-bg; + } - &:not(.ui-sort-disabled):hover { - background: $row-hover; } + &.smoke { background-color: $gray-light; } + &:last-child { border-bottom: 0; diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index 7829d722560..34fccf6f0a4 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -39,7 +39,7 @@ .table-section { white-space: nowrap; - $section-widths: 10 15 20 25 30 40 100; + $section-widths: 10 15 20 25 30 40 50 100; @each $width in $section-widths { &.section-#{$width} { flex: 0 0 #{$width + '%'}; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 3dd4a613789..798f248dad4 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -88,7 +88,6 @@ .right-sidebar { border-left: 1px solid $border-color; - height: calc(100% - #{$header-height}); } .with-performance-bar .right-sidebar.affix { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 294c59f037f..9e1371648ed 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -289,6 +289,11 @@ body { &:last-child { margin-bottom: 0; } + + &.with-button { + line-height: 34px; + } + } .page-title-empty { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index a81904d5338..8ee1bb03d55 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -767,3 +767,8 @@ $border-color-settings: #e1e1e1; Modals */ $modal-body-height: 134px; + +/* +Prometheus +*/ +$prometheus-table-row-highlight-color: $theme-gray-100; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 98d460339cd..7a6352e45f1 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -391,7 +391,7 @@ } &:hover { - background-color: $row-hover; + background-color: $dropdown-item-hover-bg; } .icon-retry { diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index b487f6278c2..86cdda0359e 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -107,7 +107,6 @@ } } - .commits-compare-switch { float: left; margin-right: 9px; @@ -179,7 +178,7 @@ .commit-detail { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; flex-grow: 1; .merge-request-branches & { @@ -200,37 +199,63 @@ } .ci-status-link { - display: inline-block; - position: relative; - top: 2px; + display: inline-flex; } - .btn-clipboard, - .btn-transparent { - padding-left: 0; - padding-right: 0; + > .ci-status-link, + > .btn, + > .commit-sha-group { + margin-left: $gl-padding-8; } +} +.commit-sha-group { + display: inline-flex; + + .label, .btn { - &:not(:first-child) { - margin-left: $gl-padding; - } + padding: $gl-vert-padding $gl-btn-padding; + border: 1px $border-color solid; + font-size: $gl-font-size; + line-height: $line-height-base; + border-radius: 0; + display: flex; + align-items: center; + } + + .label-monospace { + @extend .monospace; + user-select: text; + color: $gl-text-color; + background-color: $gray-light; } - .commit-sha { - font-size: 14px; - font-weight: $gl-font-weight-bold; + .btn svg { + top: auto; + fill: $gl-text-color-secondary; } - .ci-status-icon { - position: relative; - top: 2px; + .fa-clipboard { + color: $gl-text-color-secondary; + } + + :first-child { + border-bottom-left-radius: $border-radius-default; + border-top-left-radius: $border-radius-default; + } + + :not(:first-child) { + border-left: 0; + } + + :last-child { + border-bottom-right-radius: $border-radius-default; + border-top-right-radius: $border-radius-default; } } .commit, .generic_commit_status { - a, button { color: $gl-text-color; @@ -303,10 +328,8 @@ } } - .gpg-status-box { padding: 2px 10px; - margin-right: $gl-padding; &:empty { display: none; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 7f037582ca0..679f783b1b6 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -813,6 +813,7 @@ } .discussion-notes { + padding: 0 $gl-padding $gl-padding; min-height: 35px; &:first-child { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 58700661142..3a300086fa3 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -273,21 +273,6 @@ line-height: 1.2; } - table { - border-collapse: collapse; - padding: 0; - margin: 0; - } - - td { - vertical-align: middle; - - + td { - padding-left: 5px; - vertical-align: top; - } - } - .deploy-meta-content { border-bottom: 1px solid $white-dark; @@ -323,6 +308,26 @@ } } +.prometheus-table { + border-collapse: collapse; + padding: 0; + margin: 0; + + td { + vertical-align: middle; + + + td { + padding-left: 5px; + vertical-align: top; + } + } + + .legend-metric-title { + font-size: 12px; + vertical-align: middle; + } +} + .prometheus-svg-container { position: relative; height: 0; @@ -330,8 +335,7 @@ padding: 0; padding-bottom: 100%; - .text-metric-usage, - .legend-metric-title { + .text-metric-usage { fill: $black; font-weight: $gl-font-weight-normal; font-size: 12px; @@ -374,10 +378,6 @@ } } - .text-metric-title { - font-size: 12px; - } - .y-label-text, .x-label-text { fill: $gray-darkest; @@ -414,3 +414,7 @@ } } } + +.prometheus-table-row-highlight { + background-color: $prometheus-table-row-highlight-color; +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e21a9f0afc9..2c0ed976301 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -522,10 +522,6 @@ .with-performance-bar .right-sidebar { top: $header-height + $performance-bar-height; - - .issuable-sidebar { - height: calc(100% - #{$performance-bar-height}); - } } .sidebar-move-issue-confirmation-button { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 4a528bc2bb1..8720f821ce9 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -173,11 +173,7 @@ } .discussion-form { - background-color: $white-light; -} - -.discussion-form-container { - padding: $gl-padding-top $gl-padding $gl-padding; + padding-top: $gl-padding-top; } .discussion-notes .disabled-comment { @@ -237,12 +233,7 @@ .discussion-body, .diff-file { .discussion-reply-holder { - background-color: $white-light; - padding: 10px 16px; - - &.is-replying { - padding-bottom: $gl-padding; - } + padding-top: $gl-padding; } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 81e98f358a8..9d9cbecc958 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -47,7 +47,7 @@ ul.notes { } .timeline-entry-inner { - padding: $gl-padding $gl-btn-padding; + padding: $gl-padding 0; border-bottom: 1px solid $white-normal; } @@ -94,12 +94,6 @@ ul.notes { } } - &.note-discussion { - .timeline-entry-inner { - padding: $gl-padding 10px; - } - } - .editing-spinner { display: none; } @@ -352,6 +346,8 @@ ul.notes { } .discussion-notes { + background-color: $white-light; + &:not(:first-child) { border-top: 1px solid $white-normal; margin-top: 20px; @@ -363,10 +359,6 @@ ul.notes { } } - .notes { - background-color: $white-light; - } - a code { top: 0; margin-right: 0; @@ -647,8 +639,6 @@ ul.notes { border-bottom: 1px solid $white-normal; .timeline-entry-inner { - padding-left: $gl-padding; - padding-right: $gl-padding; border-bottom: 0; } } diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss new file mode 100644 index 00000000000..fb42dee66d2 --- /dev/null +++ b/app/assets/stylesheets/pages/pages.scss @@ -0,0 +1,60 @@ +.pages-domain-list { + &-item { + position: relative; + display: flex; + align-items: center; + + .domain-status { + display: inline-flex; + left: $gl-padding; + position: absolute; + } + + .domain-name { + flex-grow: 1; + } + + } + + &.has-verification-status > li { + padding-left: 3 * $gl-padding; + } + +} + +.status-badge { + + display: inline-flex; + margin-bottom: $gl-padding-8; + + // Most of the following settings "stolen" from btn-sm + // Border radius is overwritten for both + .label, + .btn { + padding: $gl-padding-4 $gl-padding-8; + font-size: $gl-font-size; + line-height: $gl-btn-line-height; + border-radius: 0; + display: flex; + align-items: center; + } + + .btn svg { + top: auto; + } + + :first-child { + border-bottom-left-radius: $border-radius-default; + border-top-left-radius: $border-radius-default; + } + + :not(:first-child) { + border-left: 0; + } + + :last-child { + border-bottom-right-radius: $border-radius-default; + border-top-right-radius: $border-radius-default; + } + +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 42772f13155..8d5eb2e8c5a 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -495,17 +495,17 @@ svg { fill: $gl-text-color-secondary; position: relative; - left: 5px; - top: 2px; - width: 18px; - height: 18px; + left: 1px; + top: -1px; + width: 16px; + height: 16px; } &.play { svg { - width: #{$ci-action-icon-size - 8}; - height: #{$ci-action-icon-size - 8}; - left: 8px; + width: 16px; + height: 16px; + left: 3px; } } } @@ -706,8 +706,8 @@ button.mini-pipeline-graph-dropdown-toggle { // dropdown content for big and mini pipeline .big-pipeline-graph-dropdown-menu, .mini-pipeline-graph-dropdown-menu { - width: 195px; - max-width: 195px; + width: 240px; + max-width: 240px; .scrollable-menu { padding: 0; @@ -750,7 +750,7 @@ button.mini-pipeline-graph-dropdown-toggle { height: #{$ci-action-icon-size - 6}; left: -3px; position: relative; - top: -2px; + top: -1px; &.icon-action-stop, &.icon-action-cancel { @@ -931,13 +931,11 @@ button.mini-pipeline-graph-dropdown-toggle { */ &.dropdown-menu { transform: translate(-80%, 0); - min-width: 150px; @media(min-width: $screen-md-min) { transform: translate(-50%, 0); right: auto; left: 50%; - min-width: 240px; } } } diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index ac745019319..b199f9876d3 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -210,13 +210,8 @@ } .created-personal-access-token-container { - #created-personal-access-token { - width: 90%; - display: inline; - } - .btn-clipboard { - margin-left: 5px; + border: 1px solid $border-color; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 9a770d77685..790e91e4431 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -1143,3 +1143,11 @@ pre.light-well { white-space: pre-wrap; } } + +.project-badge { + opacity: 0.9; + + &:hover { + opacity: 1; + } +} diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 34340853165..a414deb8921 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -19,7 +19,7 @@ .ide-view { display: flex; height: calc(100vh - #{$header-height}); - margin-top: 40px; + margin-top: 0; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; @@ -53,6 +53,7 @@ flex: 1; white-space: nowrap; text-overflow: ellipsis; + max-width: inherit; svg { vertical-align: middle; @@ -307,14 +308,73 @@ height: 100%; } -.multi-file-editor-btn-group { - padding: $gl-bar-padding $gl-padding; - border-top: 1px solid $white-dark; +.preview-container { + height: 100%; + overflow: auto; + + .file-container { + background-color: $gray-darker; + display: flex; + height: 100%; + align-items: center; + justify-content: center; + + text-align: center; + + .file-content { + padding: $gl-padding; + max-width: 100%; + max-height: 100%; + + img { + max-width: 90%; + max-height: 90%; + } + + .isZoomable { + cursor: pointer; + cursor: zoom-in; + + &.isZoomed { + cursor: pointer; + cursor: zoom-out; + max-width: none; + max-height: none; + margin-right: $gl-padding; + } + } + } + + .file-info { + font-size: $label-font-size; + color: $diff-image-info-color; + } + } + + .md-previewer { + padding: $gl-padding; + } +} + +.ide-mode-tabs { border-bottom: 1px solid $white-dark; - background: $white-light; + + .nav-links { + border-bottom: 0; + + li a { + padding: $gl-padding-8 $gl-padding; + line-height: $gl-btn-line-height; + } + } +} + +.ide-btn-group { + padding: $gl-padding-4 $gl-vert-padding; } .ide-status-bar { + border-top: 1px solid $white-dark; padding: $gl-bar-padding $gl-padding; background: $white-light; display: flex; @@ -456,6 +516,8 @@ display: flex; flex-direction: column; flex: 1; + max-height: 100%; + overflow: auto; } .multi-file-commit-empty-state-container { @@ -466,7 +528,7 @@ .multi-file-commit-panel-header { display: flex; align-items: center; - margin-bottom: 12px; + margin-bottom: 0; border-bottom: 1px solid $white-dark; padding: $gl-btn-padding 0; @@ -673,8 +735,14 @@ overflow: hidden; &.nav-only { + padding-top: $header-height; + + .with-performance-bar & { + padding-top: $header-height + $performance-bar-height; + } + .flash-container { - margin-top: $header-height; + margin-top: 0; margin-bottom: 0; } @@ -684,7 +752,7 @@ } .content-wrapper { - margin-top: $header-height; + margin-top: 0; padding-bottom: 0; } @@ -708,11 +776,11 @@ .with-performance-bar .ide.nav-only { .flash-container { - margin-top: #{$header-height + $performance-bar-height}; + margin-top: 0; } .content-wrapper { - margin-top: #{$header-height + $performance-bar-height}; + margin-top: 0; padding-bottom: 0; } @@ -721,10 +789,6 @@ } &.flash-shown { - .content-wrapper { - margin-top: 0; - } - .ide-view { height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height}); } diff --git a/app/assets/stylesheets/pages/repo.scss.orig b/app/assets/stylesheets/pages/repo.scss.orig new file mode 100644 index 00000000000..57b995adb64 --- /dev/null +++ b/app/assets/stylesheets/pages/repo.scss.orig @@ -0,0 +1,786 @@ +.project-refs-form, +.project-refs-target-form { + display: inline-block; +} + +.fade-enter, +.fade-leave-to { + opacity: 0; +} + +.commit-message { + @include str-truncated(250px); +} + +.editable-mode { + display: inline-block; +} + +.ide-view { + display: flex; + height: calc(100vh - #{$header-height}); + margin-top: 40px; + color: $almost-black; + border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + + &.is-collapsed { + .ide-file-list { + max-width: 250px; + } + } + + .file-status-icon { + width: 10px; + height: 10px; + } +} + +.ide-file-list { + flex: 1; + + .file { + cursor: pointer; + + &.file-open { + background: $white-normal; + } + + .ide-file-name { + flex: 1; + white-space: nowrap; + text-overflow: ellipsis; + + svg { + vertical-align: middle; + margin-right: 2px; + } + + .loading-container { + margin-right: 4px; + display: inline-block; + } + } + + .ide-file-changed-icon { + margin-left: auto; + } + + .ide-new-btn { + display: none; + margin-bottom: -4px; + margin-right: -8px; + } + + &:hover { + .ide-new-btn { + display: block; + } + } + + &.folder { + svg { + fill: $gl-text-color-secondary; + } + } + } + + a { + color: $gl-text-color; + } + + th { + position: sticky; + top: 0; + } +} + +.file-name, +.file-col-commit-message { + display: flex; + overflow: visible; + padding: 6px 12px; +} + +.multi-file-loading-container { + margin-top: 10px; + padding: 10px; + + .animation-container { + background: $gray-light; + + div { + background: $gray-light; + } + } +} + +.multi-file-table-col-commit-message { + white-space: nowrap; + width: 50%; +} + +.multi-file-edit-pane { + display: flex; + flex-direction: column; + flex: 1; + border-left: 1px solid $white-dark; + overflow: hidden; +} + +.multi-file-tabs { + display: flex; + background-color: $white-normal; + box-shadow: inset 0 -1px $white-dark; + + > ul { + display: flex; + overflow-x: auto; + } + + li { + position: relative; + } + + .dropdown { + display: flex; + margin-left: auto; + margin-bottom: 1px; + padding: 0 $grid-size; + border-left: 1px solid $white-dark; + background-color: $white-light; + + &.shadow { + box-shadow: 0 0 10px $dropdown-shadow-color; + } + + .btn { + margin-top: auto; + margin-bottom: auto; + } + } +} + +.multi-file-tab { + @include str-truncated(150px); + padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding; + background-color: $gray-normal; + border-right: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + cursor: pointer; + + svg { + vertical-align: middle; + } + + &.active { + background-color: $white-light; + border-bottom-color: $white-light; + } +} + +.multi-file-tab-close { + position: absolute; + right: 8px; + top: 50%; + width: 16px; + height: 16px; + padding: 0; + background: none; + border: 0; + border-radius: $border-radius-default; + color: $theme-gray-900; + transform: translateY(-50%); + + svg { + position: relative; + top: -1px; + } + + &:hover { + background-color: $theme-gray-200; + } + + &:focus { + background-color: $blue-500; + color: $white-light; + outline: 0; + + svg { + fill: currentColor; + } + } +} + +.multi-file-edit-pane-content { + flex: 1; + height: 0; +} + +.blob-editor-container { + flex: 1; + height: 0; + display: flex; + flex-direction: column; + justify-content: center; + + .vertical-center { + min-height: auto; + } + + .monaco-editor .lines-content .cigr { + display: none; + } + + .monaco-diff-editor.vs { + .editor.modified { + box-shadow: none; + } + + .diagonal-fill { + display: none !important; + } + + .diffOverview { + background-color: $white-light; + border-left: 1px solid $white-dark; + cursor: ns-resize; + } + + .diffViewport { + display: none; + } + + .char-insert { + background-color: $line-added-dark; + } + + .char-delete { + background-color: $line-removed-dark; + } + + .line-numbers { + color: $black-transparent; + } + + .view-overlays { + .line-insert { + background-color: $line-added; + } + + .line-delete { + background-color: $line-removed; + } + } + + .margin { + background-color: $gray-light; + border-right: 1px solid $white-normal; + + .line-insert { + border-right: 1px solid $line-added-dark; + } + + .line-delete { + border-right: 1px solid $line-removed-dark; + } + } + + .margin-view-overlays .insert-sign, + .margin-view-overlays .delete-sign { + opacity: 0.4; + } + + .cursors-layer { + display: none; + } + } +} + +.multi-file-editor-holder { + height: 100%; +} + +.multi-file-editor-btn-group { + padding: $gl-bar-padding $gl-padding; + border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + background: $white-light; +} + +.ide-status-bar { + padding: $gl-bar-padding $gl-padding; + background: $white-light; + display: flex; + justify-content: space-between; + + svg { + vertical-align: middle; + } +} + +// Not great, but this is to deal with our current output +.multi-file-preview-holder { + height: 100%; + overflow: scroll; + + .file-content.code { + display: flex; + + i { + margin-left: -10px; + } + } + + .line-numbers { + min-width: 50px; + } + + .file-content, + .line-numbers, + .blob-content, + .code { + min-height: 100%; + } +} + +.file-content.blob-no-preview { + a { + margin-left: auto; + margin-right: auto; + } +} + +.multi-file-commit-panel { + display: flex; + position: relative; + flex-direction: column; + width: 340px; + padding: 0; + background-color: $gray-light; + padding-right: 3px; + + .projects-sidebar { + display: flex; + flex-direction: column; + + .context-header { + width: auto; + margin-right: 0; + } + } + + .multi-file-commit-panel-inner { + display: flex; + flex: 1; + flex-direction: column; + } + + .multi-file-commit-panel-inner-scroll { + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + } + + &.is-collapsed { + width: 60px; + + .multi-file-commit-list { + padding-top: $gl-padding; + overflow: hidden; + } + + .multi-file-context-bar-icon { + align-items: center; + + svg { + float: none; + margin: 0; + } + } + } + + .branch-container { + border-left: 4px solid $indigo-700; + margin-bottom: $gl-bar-padding; + } + + .branch-header { + background: $white-dark; + display: flex; + } + + .branch-header-title { + flex: 1; + padding: $grid-size $gl-padding; + color: $indigo-700; + font-weight: $gl-font-weight-bold; + + svg { + vertical-align: middle; + } + } + + .branch-header-btns { + padding: $gl-vert-padding $gl-padding; + } + + .left-collapse-btn { + display: none; + background: $gray-light; + text-align: left; + border-top: 1px solid $white-dark; + + svg { + vertical-align: middle; + } + } +} + +.multi-file-context-bar-icon { + padding: 10px; + + svg { + margin-right: 10px; + float: left; + } +} + +.multi-file-commit-panel-section { + display: flex; + flex-direction: column; + flex: 1; +} + +.multi-file-commit-empty-state-container { + align-items: center; + justify-content: center; +} + +.multi-file-commit-panel-header { + display: flex; + align-items: center; + margin-bottom: 12px; + border-bottom: 1px solid $white-dark; + padding: $gl-btn-padding 0; + + &.is-collapsed { + border-bottom: 1px solid $white-dark; + + svg { + margin-left: auto; + margin-right: auto; + } + + .multi-file-commit-panel-collapse-btn { + margin-right: auto; + margin-left: auto; + border-left: 0; + } + } +} + +.multi-file-commit-panel-header-title { + display: flex; + flex: 1; + padding: 0 $gl-btn-padding; + + svg { + margin-right: $gl-btn-padding; + } +} + +.multi-file-commit-panel-collapse-btn { + border-left: 1px solid $white-dark; +} + +.multi-file-commit-list { + flex: 1; + overflow: auto; + padding: $gl-padding 0; + min-height: 60px; +} + +.multi-file-commit-list-item { + display: flex; + padding: 0; + align-items: center; + + .multi-file-discard-btn { + display: none; + margin-left: auto; + color: $gl-link-color; + padding: 0 2px; + + &:focus, + &:hover { + text-decoration: underline; + } + } + + &:hover { + background: $white-normal; + + .multi-file-discard-btn { + display: block; + } + } +} + +.multi-file-addition { + fill: $green-500; +} + +.multi-file-modified { + fill: $orange-500; +} + +.multi-file-commit-list-collapsed { + display: flex; + flex-direction: column; + + > svg { + margin-left: auto; + margin-right: auto; + } + + .file-status-icon { + width: 10px; + height: 10px; + margin-left: 3px; + } +} + +.multi-file-commit-list-path { + padding: $grid-size / 2; + padding-left: $gl-padding; + background: none; + border: 0; + text-align: left; + width: 100%; + min-width: 0; + + svg { + min-width: 16px; + vertical-align: middle; + display: inline-block; + } + + &:hover, + &:focus { + outline: 0; + } +} + +.multi-file-commit-list-file-path { + @include str-truncated(100%); + + &:hover { + text-decoration: underline; + } + + &:active { + text-decoration: none; + } +} + +.multi-file-commit-form { + padding: $gl-padding; + border-top: 1px solid $white-dark; + + .btn { + font-size: $gl-font-size; + } +} + +.multi-file-commit-message.form-control { + height: 160px; + resize: none; +} + +.dirty-diff { + // !important need to override monaco inline style + width: 4px !important; + left: 0 !important; + + &-modified { + background-color: $blue-500; + } + + &-added { + background-color: $green-600; + } + + &-removed { + height: 0 !important; + width: 0 !important; + bottom: -2px; + border-style: solid; + border-width: 5px; + border-color: transparent transparent transparent $red-500; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100px; + height: 1px; + background-color: rgba($red-500, 0.5); + } + } +} + +.ide-loading { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-empty-state { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-new-btn { + .dropdown-toggle svg { + margin-top: -2px; + margin-bottom: 2px; + } + + .dropdown-menu { + left: auto; + right: 0; + + label { + font-weight: $gl-font-weight-normal; + padding: 5px 8px; + margin-bottom: 0; + } + } +} + +.ide { + overflow: hidden; + + &.nav-only { + .flash-container { + margin-top: $header-height; + margin-bottom: 0; + } + + .alert-wrapper .flash-container .flash-alert:last-child, + .alert-wrapper .flash-container .flash-notice:last-child { + margin-bottom: 0; + } + + .content-wrapper { + margin-top: $header-height; + padding-bottom: 0; + } + + &.flash-shown { + .content-wrapper { + margin-top: 0; + } + + .ide-view { + height: calc(100vh - #{$header-height + $flash-height}); + } + } + + .projects-sidebar { + .multi-file-commit-panel-inner-scroll { + flex: 1; + } + } + } +} + +.with-performance-bar .ide.nav-only { + .flash-container { + margin-top: #{$header-height + $performance-bar-height}; + } + + .content-wrapper { + margin-top: #{$header-height + $performance-bar-height}; + padding-bottom: 0; + } + + .ide-view { + height: calc(100vh - #{$header-height + $performance-bar-height}); + } + + &.flash-shown { + .content-wrapper { + margin-top: 0; + } + + .ide-view { + height: calc( + 100vh - #{$header-height + $performance-bar-height + $flash-height} + ); + } + } +} + +.dragHandle { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + background-color: $white-dark; + + &.dragright { + right: 0; + } + + &.dragleft { + left: 0; + } +} + +.ide-commit-radios { + label { + font-weight: normal; + } + + .help-block { + margin-top: 0; + line-height: 0; + } +} + +.ide-commit-new-branch { + margin-left: 25px; +} + +.ide-external-links { + p { + margin: 0; + } +} + +.ide-sidebar-link { + padding: $gl-padding-8 $gl-padding; + background: $indigo-700; + color: $white-light; + text-decoration: none; + display: flex; + align-items: center; + + &:focus, + &:hover { + color: $white-light; + text-decoration: underline; + background: $indigo-500; + } + + &:active { + background: $indigo-800; + } +} diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index a6ca8ed5016..c410049bc0b 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -284,3 +284,23 @@ .deprecated-service { cursor: default; } + +.personal-access-tokens-never-expires-label { + color: $note-disabled-comment-color; +} + +.created-deploy-token-container { + .deploy-token-field { + width: 90%; + display: inline; + } + + .btn-clipboard { + margin-left: 5px; + } + + .deploy-token-help-block { + display: block; + margin-bottom: 0; + } +} |