Commit 6daf7092 authored by Alessio Caiazza's avatar Alessio Caiazza

Merge commit '96eb6fd3' into 11-1-stable-prepare-rc6

parents c9a5f717 96eb6fd3
......@@ -418,7 +418,7 @@ group :ed25519 do
end
# Gitaly GRPC client
gem 'gitaly-proto', '~> 0.103.0', require: 'gitaly'
gem 'gitaly-proto', '~> 0.105.0', require: 'gitaly'
gem 'grpc', '~> 1.11.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed
......
......@@ -282,7 +282,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gitaly-proto (0.103.0)
gitaly-proto (0.105.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
......@@ -1037,7 +1037,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.103.0)
gitaly-proto (~> 0.105.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
......
......@@ -285,7 +285,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gitaly-proto (0.103.0)
gitaly-proto (0.105.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
......@@ -1047,7 +1047,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.103.0)
gitaly-proto (~> 0.105.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
......
......@@ -100,12 +100,12 @@ const Api = {
},
// Return Merge Request for project
mergeRequest(projectPath, mergeRequestId) {
mergeRequest(projectPath, mergeRequestId, params = {}) {
const url = Api.buildUrl(Api.mergeRequestPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':mrid', mergeRequestId);
return axios.get(url);
return axios.get(url, { params });
},
mergeRequests(params = {}) {
......
......@@ -20,16 +20,13 @@ export default {
},
},
computed: {
...mapGetters(['commit']),
...mapGetters(['commitId']),
normalizedDiffLines() {
return this.diffLines.map(line => (line.richText ? trimFirstCharOfLineContent(line) : line));
},
diffLinesLength() {
return this.normalizedDiffLines.length;
},
commitId() {
return this.commit && this.commit.id;
},
userColorScheme() {
return window.gon.user_color_scheme;
},
......
......@@ -21,7 +21,7 @@ export default {
},
},
computed: {
...mapGetters(['commit']),
...mapGetters(['commitId']),
parallelDiffLines() {
return this.diffLines.map(line => {
const parallelLine = Object.assign({}, line);
......@@ -44,9 +44,6 @@ export default {
diffLinesLength() {
return this.parallelDiffLines.length;
},
commitId() {
return this.commit && this.commit.id;
},
userColorScheme() {
return window.gon.user_color_scheme;
},
......
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants';
export default {
isParallelView(state) {
return state.diffViewType === PARALLEL_DIFF_VIEW_TYPE;
},
isInlineView(state) {
return state.diffViewType === INLINE_DIFF_VIEW_TYPE;
},
areAllFilesCollapsed(state) {
return state.diffFiles.every(file => file.collapsed);
},
commit(state) {
return state.commit;
},
};
export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW_TYPE;
export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE;
export const areAllFilesCollapsed = state => state.diffFiles.every(file => file.collapsed);
export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default () => ({
isLoading: true,
endpoint: '',
basePath: '',
commit: null,
diffFiles: [],
mergeRequestDiffs: [],
diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
});
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import actions from '../actions';
import getters from '../getters';
import * as getters from '../getters';
import mutations from '../mutations';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
import createState from './diff_state';
export default {
state: {
isLoading: true,
endpoint: '',
basePath: '',
commit: null,
diffFiles: [],
mergeRequestDiffs: [],
diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
},
state: createState(),
getters,
actions,
mutations,
......
......@@ -66,15 +66,10 @@ export default {
},
[types.EXPAND_ALL_FILES](state) {
const diffFiles = [];
state.diffFiles.forEach(file => {
diffFiles.push({
...file,
collapsed: false,
});
});
Object.assign(state, { diffFiles });
// eslint-disable-next-line no-param-reassign
state.diffFiles = state.diffFiles.map(file => ({
...file,
collapsed: false,
}));
},
};
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AccessorUtilities from '~/lib/utils/accessor';
import eventHub from '../event_hub';
import store from '../store/';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import { isMobile, updateExistingFrequentItem } from '../utils';
import FrequentItemsSearchInput from './frequent_items_search_input.vue';
import FrequentItemsList from './frequent_items_list.vue';
import frequentItemsMixin from './frequent_items_mixin';
export default {
store,
components: {
LoadingIcon,
FrequentItemsSearchInput,
FrequentItemsList,
},
mixins: [frequentItemsMixin],
props: {
currentUserName: {
type: String,
required: true,
},
currentItem: {
type: Object,
required: true,
},
},
computed: {
...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']),
...mapGetters(['hasSearchQuery']),
translations() {
return this.getTranslations(['loadingMessage', 'header']);
},
},
created() {
const { namespace, currentUserName, currentItem } = this;
const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`;
this.setNamespace(namespace);
this.setStorageKey(storageKey);
if (currentItem.id) {
this.logItemAccess(storageKey, currentItem);
}
eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
methods: {
...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']),
dropdownOpenHandler() {
if (this.searchQuery === '' || isMobile()) {
this.fetchFrequentItems();
}
},
logItemAccess(storageKey, item) {
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return false;
}
// Check if there's any frequent items list set
const storedRawItems = localStorage.getItem(storageKey);
const storedFrequentItems = storedRawItems
? JSON.parse(storedRawItems)
: [{ ...item, frequency: 1 }]; // No frequent items list set, set one up.
// Check if item already exists in list
const itemMatchIndex = storedFrequentItems.findIndex(
frequentItem => frequentItem.id === item.id,
);
if (itemMatchIndex > -1) {
storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem(
storedFrequentItems[itemMatchIndex],
item,
);
} else {
if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) {
storedFrequentItems.shift();
}
storedFrequentItems.push({ ...item, frequency: 1 });
}
return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems));
},
},
};
</script>
<template>
<div>
<frequent-items-search-input
:namespace="namespace"
/>
<loading-icon
v-if="isLoadingItems"
:label="translations.loadingMessage"
class="loading-animation prepend-top-20"
size="2"
/>
<div
v-if="!isLoadingItems && !hasSearchQuery"
class="section-header"
>
{{ translations.header }}
</div>
<frequent-items-list
v-if="!isLoadingItems"
:items="items"
:namespace="namespace"
:has-search-query="hasSearchQuery"
:is-fetch-failed="isFetchFailed"
:matcher="searchQuery"
/>
</div>
</template>
<script>
import { s__ } from '../../locale';
import projectsListItem from './projects_list_item.vue';
import FrequentItemsListItem from './frequent_items_list_item.vue';
import frequentItemsMixin from './frequent_items_mixin';
export default {
components: {
projectsListItem,
FrequentItemsListItem,
},
mixins: [frequentItemsMixin],
props: {
matcher: {
type: String,
items: {
type: Array,
required: true,
},
projects: {
type: Array,
hasSearchQuery: {
type: Boolean,
required: true,
},
searchFailed: {
isFetchFailed: {
type: Boolean,
required: true,
},
matcher: {
type: String,
required: true,
},
},
computed: {
translations() {
return this.getTranslations([
'itemListEmptyMessage',
'itemListErrorMessage',
'searchListEmptyMessage',
'searchListErrorMessage',
]);
},
isListEmpty() {
return this.projects.length === 0;
return this.items.length === 0;
},
listEmptyMessage() {
return this.searchFailed ?
s__('ProjectsDropdown|Something went wrong on our end.') :
s__('ProjectsDropdown|Sorry, no projects matched your search');
if (this.hasSearchQuery) {
return this.isFetchFailed
? this.translations.searchListErrorMessage
: this.translations.searchListEmptyMessage;
}
return this.isFetchFailed
? this.translations.itemListErrorMessage
: this.translations.itemListEmptyMessage;
},
},
};
</script>
<template>
<div
class="projects-list-search-container"
>
<ul
class="list-unstyled"
>
<div class="frequent-items-list-container">
<ul class="list-unstyled">
<li
v-if="isListEmpty"
:class="{ 'section-failure': searchFailed }"
:class="{ 'section-failure': isFetchFailed }"
class="section-empty"
>
{{ listEmptyMessage }}
</li>
<projects-list-item
v-for="(project, index) in projects"
<frequent-items-list-item
v-for="item in items"
v-else
:key="index"
:project-id="project.id"
:project-name="project.name"
:namespace="project.namespace"
:web-url="project.webUrl"
:avatar-url="project.avatarUrl"
:key="item.id"
:item-id="item.id"
:item-name="item.name"
:namespace="item.namespace"
:web-url="item.webUrl"
:avatar-url="item.avatarUrl"
:matcher="matcher"
/>
</ul>
......
<script>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import Identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
Identicon,
},
props: {
matcher: {
type: String,
required: false,
},
itemId: {
type: Number,
required: true,
},
itemName: {
type: String,
required: true,
},
namespace: {
type: String,
required: false,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedItemName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.itemName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.itemName;
},
/**
* Smartly truncates item namespace by doing two things;
* 1. Only include Group names in path by removing item name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of item name from namespace) can be
* done from backend but doing so involves migration of
* existing item namespaces which is not wise thing to do.
*/
truncatedNamespace() {
if (!this.namespace) {
return null;
}
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
},
},
};
</script>
<template>
<li class="frequent-items-list-item-container">
<a
:href="webUrl"
class="clearfix"
>
<div class="frequent-items-item-avatar-container">
<img
v-if="hasAvatar"
:src="avatarUrl"
class="avatar s32"
/>
<identicon
v-else
:entity-id="itemId"
:entity-name="itemName"
size-class="s32"
/>
</div>
<div class="frequent-items-item-metadata-container">
<div
:title="itemName"
class="frequent-items-item-title"
v-html="highlightedItemName"
>
</div>
<div
v-if="truncatedNamespace"
:title="namespace"
class="frequent-items-item-namespace"
>
{{ truncatedNamespace }}
</div>
</div>
</a>
</li>
</template>
import { TRANSLATION_KEYS } from '../constants';
export default {
props: {
namespace: {
type: String,
required: true,
},
},
methods: {
getTranslations(keys) {
const translationStrings = keys.reduce(
(acc, key) => ({
...acc,
[key]: TRANSLATION_KEYS[this.namespace][key],
}),
{},
);
return translationStrings;
},
},
};
<script>
import _ from 'underscore';
import { mapActions } from 'vuex';
import eventHub from '../event_hub';
import frequentItemsMixin from './frequent_items_mixin';
export default {
mixins: [frequentItemsMixin],
data() {
return {
searchQuery: '',
};
},
computed: {
translations() {
return this.getTranslations(['searchInputPlaceholder']);
},
},
watch: {
searchQuery: _.debounce(function debounceSearchQuery() {
this.setSearchQuery(this.searchQuery);
}, 500),
},
mounted() {
eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus);
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus);
},
methods: {
...mapActions(['setSearchQuery']),
setFocus() {
this.$refs.search.focus();
},
},
};
</script>
<template>
<div class="search-input-container d-none d-sm-block">
<input
ref="search"
v-model="searchQuery"
:placeholder="translations.searchInputPlaceholder"
type="search"
class="form-control"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
>
</i>
</div>
</template>
import { s__ } from '~/locale';
export const FREQUENT_ITEMS = {
MAX_COUNT: 20,
LIST_COUNT_DESKTOP: 5,
LIST_COUNT_MOBILE: 3,
ELIGIBLE_FREQUENCY: 3,
};
export const HOUR_IN_MS = 3600000;
export const STORAGE_KEY = {
projects: 'frequent-projects',
groups: 'frequent-groups',
};
export const TRANSLATION_KEYS = {
projects: {
loadingMessage: s__('ProjectsDropdown|Loading projects'),
header: s__('ProjectsDropdown|Frequently visited'),
itemListErrorMessage: s__(
'ProjectsDropdown|This feature requires browser localStorage support',
),
itemListEmptyMessage: s__('ProjectsDropdown|Projects you visit often will appear here'),
searchListErrorMessage: s__('ProjectsDropdown|Something went wrong on our end.'),
searchListEmptyMessage: s__('ProjectsDropdown|Sorry, no projects matched your search'),
searchInputPlaceholder: s__('ProjectsDropdown|Search your projects'),
},
groups: {
loadingMessage: s__('GroupsDropdown|Loading groups'),
header: s__('GroupsDropdown|Frequently visited'),
itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'),
itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'),
searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'),
searchListEmptyMessage: s__('GroupsDropdown|Sorry, no groups matched your search'),
searchInputPlaceholder: s__('GroupsDropdown|Search your groups'),
},
};
import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import eventHub from '~/frequent_items/event_hub';
import frequentItems from './components/app.vue';
Vue.use(Translate);
const frequentItemDropdowns = [
{
namespace: 'projects',
key: 'project',
},
{
namespace: 'groups',
key: 'group',
},
];
document.addEventListener('DOMContentLoaded', () => {
frequentItemDropdowns.forEach(dropdown => {
const { namespace, key } = dropdown;
const el = document.getElementById(`js-${namespace}-dropdown`);
const navEl = document.getElementById(`nav-${namespace}-dropdown`);
// Don't do anything if element doesn't exist (No groups dropdown)
// This is for when the user accesses GitLab without logging in
if (!el || !navEl) {
return;
}
$(navEl).on('shown.bs.dropdown', () => {
eventHub.$emit(`${namespace}-dropdownOpen`);
});
// eslint-disable-next-line no-new
new Vue({
el,
components: {
frequentItems,
},
data() {
const { dataset } = this.$options.el;
const item = {
id: Number(dataset[`${key}Id`]),
name: dataset[`${key}Name`],
namespace: dataset[`${key}Namespace`],
webUrl: dataset[`${key}WebUrl`],
avatarUrl: dataset[`${key}AvatarUrl`] || null,
lastAccessedOn: Date.now(),
};
return {
currentUserName: dataset.userName,
currentItem: item,
};
},
render(createElement) {
return createElement('frequent-items', {
props: {
namespace,
currentUserName: this.currentUserName,
currentItem: this.currentItem,
},
});
},
});
});
});
import Api from '~/api';
import AccessorUtilities from '~/lib/utils/accessor';
import * as types from './mutation_types';
import { getTopFrequentItems } from '../utils';
export const setNamespace = ({ commit }, namespace) => {
commit(types.SET_NAMESPACE, namespace);
};
export const setStorageKey = ({ commit }, key) => {
commit(types.SET_STORAGE_KEY, key);
};
export const requestFrequentItems = ({ commit }) => {
commit(types.REQUEST_FREQUENT_ITEMS);
};
export const receiveFrequentItemsSuccess = ({ commit }, data) => {
commit(types.RECEIVE_FREQUENT_ITEMS_SUCCESS, data);
};
export const receiveFrequentItemsError = ({ commit }) => {
commit(types.RECEIVE_FREQUENT_ITEMS_ERROR);
};
export const fetchFrequentItems = ({ state, dispatch }) => {
dispatch('requestFrequentItems');
if (AccessorUtilities.isLocalStorageAccessSafe()) {
const storedFrequentItems = JSON.parse(localStorage.getItem(state.storageKey));
dispatch(
'receiveFrequentItemsSuccess',
!storedFrequentItems ? [] : getTopFrequentItems(storedFrequentItems),
);
} else {
dispatch('receiveFrequentItemsError');
}
};
export const requestSearchedItems = ({ commit }) => {
commit(types.REQUEST_SEARCHED_ITEMS);
};
export const receiveSearchedItemsSuccess = ({ commit }, data) => {
commit(types.RECEIVE_SEARCHED_ITEMS_SUCCESS, data);
};
export const receiveSearchedItemsError = ({ commit }) => {
commit(types.RECEIVE_SEARCHED_ITEMS_ERROR);
};
export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => {
dispatch('requestSearchedItems');
const params = {
simple: true,
per_page: 20,
membership: !!gon.current_user_id,
};
if (state.namespace === 'projects') {
params.order_by = 'last_activity_at';
}
return Api[state.namespace](searchQuery, params)
.then(results => {
dispatch('receiveSearchedItemsSuccess', results);
})
.catch(() => {
dispatch('receiveSearchedItemsError');
});
};
export const setSearchQuery = ({ commit, dispatch }, query) => {
commit(types.SET_SEARCH_QUERY, query);
if (query) {
dispatch('fetchSearchedItems', query);
} else {
dispatch('fetchFrequentItems');
}
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const hasSearchQuery = state => state.searchQuery !== '';
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
getters,
mutations,
state: state(),
});
export const SET_NAMESPACE = 'SET_NAMESPACE';
export const SET_STORAGE_KEY = 'SET_STORAGE_KEY';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS';
export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS';
export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR';
export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS';
export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS';
export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_NAMESPACE](state, namespace) {
Object.assign(state, {
namespace,
});
},
[types.SET_STORAGE_KEY](state, storageKey) {
Object.assign(state, {
storageKey,
});
},
[types.SET_SEARCH_QUERY](state, searchQuery) {
const hasSearchQuery = searchQuery !== '';
Object.assign(state, {
searchQuery,
isLoadingItems: true,
hasSearchQuery,
});
},
[types.REQUEST_FREQUENT_ITEMS](state) {
Object.assign(state, {
isLoadingItems: true,
hasSearchQuery: false,
});
},
[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](state, rawItems) {
Object.assign(state, {
items: rawItems,
isLoadingItems: false,
hasSearchQuery: false,
isFetchFailed: false,
});
},
[types.RECEIVE_FREQUENT_ITEMS_ERROR](state) {
Object.assign(state, {
isLoadingItems: false,
hasSearchQuery: false,
isFetchFailed: true,
});
},
[types.REQUEST_SEARCHED_ITEMS](state) {
Object.assign(state, {
isLoadingItems: true,
hasSearchQuery: true,
});
},
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) {
Object.assign(state, {
items: rawItems.map(rawItem => ({
id: rawItem.id,
name: rawItem.name,
namespace: rawItem.name_with_namespace || rawItem.full_name,
webUrl: rawItem.web_url,
avatarUrl: rawItem.avatar_url,
})),
isLoadingItems: false,
hasSearchQuery: true,
isFetchFailed: false,
});
},
[types.RECEIVE_SEARCHED_ITEMS_ERROR](state) {
Object.assign(state, {
isLoadingItems: false,
hasSearchQuery: true,
isFetchFailed: true,
});
},
};
export default () => ({
namespace: '',
storageKey: '',
searchQuery: '',
isLoadingItems: false,
isFetchFailed: false,
items: [],
});
import _ from 'underscore';
import bp from '~/breakpoints';
import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants';
export const isMobile = () => {
const screenSize = bp.getBreakpointSize();
return screenSize === 'sm' || screenSize === 'xs';
};
export const getTopFrequentItems = items => {
if (!items) {
return [];
}
const frequentItemsCount = isMobile()
? FREQUENT_ITEMS.LIST_COUNT_MOBILE
: FREQUENT_ITEMS.LIST_COUNT_DESKTOP;
const frequentItems = items.filter(item => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY);
if (!frequentItems || frequentItems.length === 0) {
return [];
}
frequentItems.sort((itemA, itemB) => {
// Sort all frequent items in decending order of frequency
// and then by lastAccessedOn with recent most first
if (itemA.frequency !== itemB.frequency) {
return itemB.frequency - itemA.frequency;
} else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) {
return itemB.lastAccessedOn - itemA.lastAccessedOn;
}
return 0;
});
return _.first(frequentItems, frequentItemsCount);
};
export const updateExistingFrequentItem = (frequentItem, item) => {
const accessedOverHourAgo =
Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / HOUR_IN_MS > 1;
return {
...item,
frequency: accessedOverHourAgo ? frequentItem.frequency + 1 : frequentItem.frequency,
lastAccessedOn: accessedOverHourAgo ? Date.now() : frequentItem.lastAccessedOn,
};
};
<script>
import { mapGetters } from 'vuex';
import Icon from '../../../vue_shared/components/icon.vue';
import TitleComponent from '../../../issue_show/components/title.vue';
import DescriptionComponent from '../../../issue_show/components/description.vue';
export default {
components: {
Icon,
TitleComponent,
DescriptionComponent,
},
computed: {
...mapGetters(['currentMergeRequest']),
},
};
</script>
<template>
<div class="ide-merge-request-info h-100 d-flex flex-column">
<div class="detail-page-header">
<icon
name="git-merge"
class="align-self-center append-right-8"
/>
<strong>
!{{ currentMergeRequest.iid }}
</strong>
</div>
<div class="issuable-details">
<title-component
:issuable-ref="currentMergeRequest.iid"
:title-html="currentMergeRequest.title_html"
:title-text="currentMergeRequest.title"
/>
<description-component
:description-html="currentMergeRequest.description_html"
:description-text="currentMergeRequest.description"
:can-update="false"
/>
</div>
</div>
</template>
......@@ -5,6 +5,7 @@ import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants';
import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue';
export default {
......@@ -16,9 +17,10 @@ export default {
PipelinesList,
JobsDetail,
ResizablePanel,
MergeRequestInfo,
},
computed: {
...mapState(['rightPane']),
...mapState(['rightPane', 'currentMergeRequestId']),
pipelinesActive() {
return (
this.rightPane === rightSidebarViews.pipelines ||
......@@ -54,10 +56,33 @@ export default {
</resizable-panel>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
<li
v-if="currentMergeRequestId"
>
<button
v-tooltip
:title="__('Merge Request')"
:aria-label="__('Merge Request')"
:class="{
active: rightPane === $options.rightSidebarViews.mergeRequestInfo
}"
data-container="body"
data-placement="left"
class="ide-sidebar-link is-right"
type="button"
@click="clickTab($event, $options.rightSidebarViews.mergeRequestInfo)"
>
<icon
:size="16"
name="text-description"
/>
</button>
</li>
<li>
<button
v-tooltip
:title="__('Pipelines')"
:aria-label="__('Pipelines')"
:class="{
active: pipelinesActive
}"
......
......@@ -31,6 +31,7 @@ export const diffModes = {
export const rightSidebarViews = {
pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info',
};
export const stageKeys = {
......
......@@ -40,8 +40,8 @@ export default {
getProjectData(namespace, project) {
return Api.project(`${namespace}/${project}`);
},
getProjectMergeRequestData(projectId, mergeRequestId) {
return Api.mergeRequest(projectId, mergeRequestId);
getProjectMergeRequestData(projectId, mergeRequestId, params = {}) {
return Api.mergeRequest(projectId, mergeRequestId, params);
},
getProjectMergeRequestChanges(projectId, mergeRequestId) {
return Api.mergeRequestChanges(projectId, mergeRequestId);
......
......@@ -9,7 +9,7 @@ export const getMergeRequestData = (
new Promise((resolve, reject) => {
if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) {
service
.getProjectMergeRequestData(projectId, mergeRequestId)
.getProjectMergeRequestData(projectId, mergeRequestId, { render_html: true })
.then(({ data }) => {
commit(types.SET_MERGE_REQUEST, {
projectPath: projectId,
......
......@@ -108,6 +108,11 @@
type: String,
required: true,
},
markdownVersion: {
type: Number,
required: false,
default: 0,
},
projectPath: {
type: String,
required: true,
......@@ -282,6 +287,7 @@
:issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
:markdown-version="markdownVersion"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-delete-button="showDeleteButton"
......
......@@ -20,6 +20,11 @@
type: String,
required: true,
},
markdownVersion: {
type: Number,
required: false,
default: 0,
},
canAttachFile: {
type: Boolean,
required: false,
......@@ -47,6 +52,7 @@
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
>
......
......@@ -35,6 +35,11 @@
type: String,
required: true,
},
markdownVersion: {
type: Number,
required: false,
default: 0,
},
projectPath: {
type: String,
required: true,
......@@ -97,6 +102,7 @@
:form-state="formState"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
/>
......
<script>
import animateMixin from '../mixins/animate';
import eventHub from '../event_hub';
import tooltip from '../../vue_shared/directives/tooltip';
import { spriteIcon } from '../../lib/utils/common_utils';
import animateMixin from '../mixins/animate';
import eventHub from '../event_hub';
import tooltip from '../../vue_shared/directives/tooltip';
import { spriteIcon } from '../../lib/utils/common_utils';
export default {
directives: {
tooltip,
export default {
directives: {
tooltip,
},
mixins: [animateMixin],
props: {
issuableRef: {
type: [String, Number],
required: true,
},
mixins: [animateMixin],
props: {
issuableRef: {
type: String,
required: true,
},
canUpdate: {
required: false,
type: Boolean,
default: false,
},
titleHtml: {
type: String,
required: true,
},
titleText: {
type: String,
required: true,
},
showInlineEditButton: {
type: Boolean,
required: false,
default: false,
},
canUpdate: {
required: false,
type: Boolean,
default: false,
},
data() {
return {
preAnimation: false,
pulseAnimation: false,
titleEl: document.querySelector('title'),
};
titleHtml: {
type: String,
required: true,
},
computed: {
pencilIcon() {
return spriteIcon('pencil', 'link-highlight');
},
titleText: {
type: String,
required: true,
},
watch: {
titleHtml() {
this.setPageTitle();
this.animateChange();
},
showInlineEditButton: {
type: Boolean,
required: false,
default: false,
},
methods: {
setPageTitle() {
const currentPageTitleScope = this.titleEl.innerText.split('·');
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
this.titleEl.textContent = currentPageTitleScope.join('·');
},
edit() {
eventHub.$emit('open.form');
},
},
data() {
return {
preAnimation: false,
pulseAnimation: false,
titleEl: document.querySelector('title'),
};
},
computed: {
pencilIcon() {
return spriteIcon('pencil', 'link-highlight');
},
};
},
watch: {
titleHtml() {
this.setPageTitle();
this.animateChange();
},
},
methods: {
setPageTitle() {
const currentPageTitleScope = this.titleEl.innerText.split('·');
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
this.titleEl.textContent = currentPageTitleScope.join('·');
},
edit() {
eventHub.$emit('open.form');
},
},
};
</script>
<template>
......
......@@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader';
import initLogoAnimation from './logo';
import './milestone_select';
import './projects_dropdown';
import './frequent_items';
import initBreadcrumbs from './breadcrumb';
import initDispatcher from './dispatcher';
......
......@@ -1251,13 +1251,15 @@ export default class Notes {
var postUrl = $originalContentEl.data('postUrl');
var targetId = $originalContentEl.data('targetId');
var targetType = $originalContentEl.data('targetType');
var markdownVersion = $originalContentEl.data('markdownVersion');
this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm
.find('form')
.attr('action', `${postUrl}?html=true`)
.attr('data-remote', 'true');
.attr('data-remote', 'true')
.attr('data-markdown-version', markdownVersion);
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
$editForm
......
......@@ -34,6 +34,11 @@ export default {
type: String,
required: true,
},
markdownVersion: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
......@@ -344,6 +349,7 @@ Please check your network connection and try again.`;
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:markdown-version="markdownVersion"
:add-spacing-classes="false">
<textarea
id="note-body"
......
......@@ -92,6 +92,7 @@ export default {
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
:markdown-version="note.cached_markdown_version"
@handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler"
/>
......
......@@ -24,6 +24,11 @@ export default {
required: false,
default: 0,
},
markdownVersion: {
type: Number,
required: false,
default: 0,
},
saveButtonTitle: {
type: String,
required: false,
......@@ -156,6 +161,7 @@ export default {
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false">
<textarea
......
......@@ -43,6 +43,11 @@ export default {
required: false,
default: true,
},
markdownVersion: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
......@@ -192,6 +197,7 @@ export default {
<comment-form
:noteable-type="noteableType"
:markdown-version="markdownVersion"
/>
</div>
</template>
......@@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => {
const notesDataset = document.getElementById('js-vue-notes').dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData);
const { markdownVersion } = notesDataset;
let currentUserData = {};
noteableData.noteableType = notesDataset.noteableType;
......@@ -33,6 +34,7 @@ document.addEventListener('DOMContentLoaded', () => {
return {
noteableData,
currentUserData,
markdownVersion,
notesData: JSON.parse(notesDataset.notesData),
};
},
......@@ -42,6 +44,7 @@ document.addEventListener('DOMContentLoaded', () => {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
markdownVersion: this.markdownVersion,
},
});
},
......
import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
document.addEventListener('DOMContentLoaded', () => {
const input = document.querySelector('.js-add-ssh-key-validation-input');
const warning = document.querySelector('.js-add-ssh-key-validation-warning');
const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit');
const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit');
const addSshKeyValidation = new AddSshKeyValidation(
input,
warning,
originalSubmit,
confirmSubmit,
);
addSshKeyValidation.register();
});
......@@ -28,12 +28,16 @@ MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function ($form) {
var mdText;
var markdownVersion;
var url;
var preview = $form.find('.js-md-preview');
var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
mdText = $form.find('textarea.markdown-area').val();
markdownVersion = $form.attr('data-markdown-version');
url = this.versionedPreviewPath(preview.data('url'), markdownVersion);
if (mdText.trim().length === 0) {
preview.text(this.emptyMessage);
......@@ -59,6 +63,14 @@ MarkdownPreview.prototype.showPreview = function ($form) {
}
};
MarkdownPreview.prototype.versionedPreviewPath = function (markdownPreviewPath, markdownVersion) {
if (typeof markdownVersion === 'undefined') {
return markdownPreviewPath;
}
return `${markdownPreviewPath}${markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'}markdown_version=${markdownVersion}`;
};
MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
if (!url) {
return;
......
export default class AddSshKeyValidation {
constructor(inputElement, warningElement, originalSubmitElement, confirmSubmitElement) {
this.inputElement = inputElement;
this.form = inputElement.form;
this.warningElement = warningElement;
this.originalSubmitElement = originalSubmitElement;
this.confirmSubmitElement = confirmSubmitElement;
this.isValid = false;
}
register() {
this.form.addEventListener('submit', event => this.submit(event));
this.confirmSubmitElement.addEventListener('click', () => {
this.isValid = true;
this.form.submit();
});
this.inputElement.addEventListener('input', () => this.toggleWarning(false));
}
submit(event) {
this.isValid = AddSshKeyValidation.isPublicKey(this.inputElement.value);
if (this.isValid) return true;
event.preventDefault();
this.toggleWarning(true);
return false;
}
toggleWarning(isVisible) {
this.warningElement.classList.toggle('hide', !isVisible);
this.originalSubmitElement.classList.toggle('hide', isVisible);
}
static isPublicKey(value) {
return /^(ssh|ecdsa-sha2)-/.test(value);
}
}
<script>
import bs from '../../breakpoints';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import projectsListFrequent from './projects_list_frequent.vue';
import projectsListSearch from './projects_list_search.vue';
import search from './search.vue';
export default {
components: {
search,
loadingIcon,
projectsListFrequent,
projectsListSearch,
},
props: {
currentProject: {
type: Object,
required: true,
},
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
isLoadingProjects: false,
isFrequentsListVisible: false,
isSearchListVisible: false,
isLocalStorageFailed: false,
isSearchFailed: false,
searchQuery: '',
};
},
computed: {
frequentProjects() {
return this.store.getFrequentProjects();
},
searchProjects() {
return this.store.getSearchedProjects();
},
},
created() {
if (this.currentProject.id) {
this.logCurrentProjectAccess();
}
eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
eventHub.$on('searchProjects', this.fetchSearchedProjects);
eventHub.$on('searchCleared', this.handleSearchClear);
eventHub.$on('searchFailed', this.handleSearchFailure);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
eventHub.$off('searchProjects', this.fetchSearchedProjects);
eventHub.$off('searchCleared', this.handleSearchClear);
eventHub.$off('searchFailed', this.handleSearchFailure);
},
methods: {
toggleFrequentProjectsList(state) {
this.isLoadingProjects = !state;
this.isSearchListVisible = !state;
this.isFrequentsListVisible = state;
},
toggleSearchProjectsList(state) {
this.isLoadingProjects = !state;
this.isFrequentsListVisible = !state;
this.isSearchListVisible = state;
},
toggleLoader(state) {
this.isFrequentsListVisible = !state;
this.isSearchListVisible = !state;
this.isLoadingProjects = state;
},
fetchFrequentProjects() {
const screenSize = bs.getBreakpointSize();
if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) {
this.toggleSearchProjectsList(true);
} else {
this.toggleLoader(true);
this.isLocalStorageFailed = false;
const projects = this.service.getFrequentProjects();
if (projects) {
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects(projects);
} else {
this.isLocalStorageFailed = true;
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects([]);
}
}
},
fetchSearchedProjects(searchQuery) {
this.searchQuery = searchQuery;
this.toggleLoader(true);
this.service
.getSearchedProjects(this.searchQuery)
.then(res => res.json())
.then(results => {
this.toggleSearchProjectsList(true);
this.store.setSearchedProjects(results);
})
.catch(() => {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
});
},
logCurrentProjectAccess() {
this.service.logProjectAccess(this.currentProject);
},
handleSearchClear() {
this.searchQuery = '';
this.toggleFrequentProjectsList(true);
this.store.clearSearchedProjects();
},
handleSearchFailure() {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
},
},
};
</script>
<template>
<div>
<search/>
<loading-icon
v-if="isLoadingProjects"
:label="s__('ProjectsDropdown|Loading projects')"
class="loading-animation prepend-top-20"
size="2"
/>
<div
v-if="isFrequentsListVisible"
class="section-header"
>
{{ s__('ProjectsDropdown|Frequently visited') }}
</div>
<projects-list-frequent
v-if="isFrequentsListVisible"
:local-storage-failed="isLocalStorageFailed"
:projects="frequentProjects"
/>
<projects-list-search
v-if="isSearchListVisible"
:search-failed="isSearchFailed"
:matcher="searchQuery"
:projects="searchProjects"
/>
</div>
</template>
<script>
import { s__ } from '../../locale';
import projectsListItem from './projects_list_item.vue';
export default {
components: {
projectsListItem,
},
props: {
projects: {
type: Array,
required: true,
},
localStorageFailed: {
type: Boolean,
required: true,
},
},
computed: {
isListEmpty() {
return this.projects.length === 0;
},
listEmptyMessage() {
return this.localStorageFailed ?
s__('ProjectsDropdown|This feature requires browser localStorage support') :
s__('ProjectsDropdown|Projects you visit often will appear here');
},
},
};
</script>
<template>
<div
class="projects-list-frequent-container"
>
<ul
class="list-unstyled"
>
<li
v-if="isListEmpty"
class="section-empty"
>
{{ listEmptyMessage }}
</li>
<projects-list-item
v-for="(project, index) in projects"
v-else
:key="index"
:project-id="project.id"
:project-name="project.name"
:namespace="project.namespace"
:web-url="project.webUrl"
:avatar-url="project.avatarUrl"
/>
</ul>
</div>
</template>
<script>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
identicon,
},
props: {
matcher: {
type: String,
required: false,
},
projectId: {
type: Number,
required: true,
},
projectName: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedProjectName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.projectName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.projectName;
},
/**
* Smartly truncates project namespace by doing two things;
* 1. Only include Group names in path by removing project name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of project name from namespace) can be
* done from backend but doing so involves migration of
* existing project namespaces which is not wise thing to do.
*/
truncatedNamespace() {
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
},
},
};
</script>
<template>
<li
class="projects-list-item-container"
>
<a
:href="webUrl"
class="clearfix"
>
<div
class="project-item-avatar-container"
>
<img
v-if="hasAvatar"
:src="avatarUrl"
class="avatar s32"
/>
<identicon
v-else
:entity-id="projectId"
:entity-name="projectName"
size-class="s32"
/>
</div>
<div
class="project-item-metadata-container"
>
<div
:title="projectName"
class="project-title"
v-html="highlightedProjectName"
>
</div>
<div
:title="namespace"
class="project-namespace"
>{{ truncatedNamespace }}</div>
</div>
</a>
</li>
</template>
<script>
import _ from 'underscore';
import eventHub from '../event_hub';
export default {
data() {
return {
searchQuery: '',
};
},
watch: {
searchQuery() {
this.handleInput();
},
},
mounted() {
eventHub.$on('dropdownOpen', this.setFocus);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.setFocus);
},
methods: {
setFocus() {
this.$refs.search.focus();
},
emitSearchEvents() {
if (this.searchQuery) {
eventHub.$emit('searchProjects', this.searchQuery);
} else {
eventHub.$emit('searchCleared');
}
},
/**
* Callback function within _.debounce is intentionally
* kept as ES5 `function() {}` instead of ES6 `() => {}`
* as it otherwise messes up function context
* and component reference is no longer accessible via `this`
*/
// eslint-disable-next-line func-names
handleInput: _.debounce(function () {
this.emitSearchEvents();
}, 500),
},
};
</script>
<template>
<div
class="search-input-container d-none d-sm-block"
>
<input
ref="search"
v-model="searchQuery"
:placeholder="s__('ProjectsDropdown|Search your projects')"
type="search"
class="form-control"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
>
</i>
</div>
</template>
export const FREQUENT_PROJECTS = {
MAX_COUNT: 20,
LIST_COUNT_DESKTOP: 5,
LIST_COUNT_MOBILE: 3,
ELIGIBLE_FREQUENCY: 3,
};
export const HOUR_IN_MS = 3600000;
export const STORAGE_KEY = 'frequent-projects';
import $ from 'jquery';
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import eventHub from './event_hub';
import ProjectsService from './service/projects_service';
import ProjectsStore from './store/projects_store';
import projectsDropdownApp from './components/app.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-projects-dropdown');
const navEl = document.getElementById('nav-projects-dropdown');
// Don't do anything if element doesn't exist (No projects dropdown)
// This is for when the user accesses GitLab without logging in
if (!el || !navEl) {
return;
}
$(navEl).on('shown.bs.dropdown', () => {
eventHub.$emit('dropdownOpen');
});
// eslint-disable-next-line no-new
new Vue({
el,
components: {
projectsDropdownApp,
},
data() {
const { dataset } = this.$options.el;
const store = new ProjectsStore();
const service = new ProjectsService(dataset.userName);
const project = {
id: Number(dataset.projectId),
name: dataset.projectName,
namespace: dataset.projectNamespace,
webUrl: dataset.projectWebUrl,
avatarUrl: dataset.projectAvatarUrl || null,
lastAccessedOn: Date.now(),
};
return {
store,
service,
state: store.state,
currentUserName: dataset.userName,
currentProject: project,
};
},
render(createElement) {
return createElement('projects-dropdown-app', {
props: {
currentUserName: this.currentUserName,
currentProject: this.currentProject,
store: this.store,
service: this.service,
},
});
},
});
});
import _ from 'underscore';
import Vue from 'vue';
import VueResource from 'vue-resource';
import bp from '../../breakpoints';
import Api from '../../api';
import AccessorUtilities from '../../lib/utils/accessor';
import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants';
Vue.use(VueResource);
export default class ProjectsService {
constructor(currentUserName) {
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.currentUserName = currentUserName;
this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`;
this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath));
}
getSearchedProjects(searchQuery) {
return this.projectsPath.get({
simple: true,
per_page: 20,
membership: !!gon.current_user_id,
order_by: 'last_activity_at',
search: searchQuery,
});
}
getFrequentProjects() {
if (this.isLocalStorageAvailable) {
return this.getTopFrequentProjects();
}
return null;
}
logProjectAccess(project) {
let matchFound = false;
let storedFrequentProjects;
if (this.isLocalStorageAvailable) {
const storedRawProjects = localStorage.getItem(this.storageKey);
// Check if there's any frequent projects list set
if (!storedRawProjects) {
// No frequent projects list set, set one up.
storedFrequentProjects = [];
storedFrequentProjects.push({ ...project, frequency: 1 });
} else {
// Check if project is already present in frequents list
// When found, update metadata of it.
storedFrequentProjects = JSON.parse(storedRawProjects).map(projectItem => {
if (projectItem.id === project.id) {
matchFound = true;
const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS;
const updatedProject = {
...project,
frequency: projectItem.frequency,
lastAccessedOn: projectItem.lastAccessedOn,
};
// Check if duration since last access of this project
// is over an hour
if (diff > 1) {
return {
...updatedProject,
frequency: updatedProject.frequency + 1,
lastAccessedOn: Date.now(),
};
}
return {
...updatedProject,
};
}
return projectItem;
});
// Check whether currently logged project is present in frequents list
if (!matchFound) {
// We always keep size of frequents collection to 20 projects
// out of which only 5 projects with
// highest value of `frequency` and most recent `lastAccessedOn`
// are shown in projects dropdown
if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) {
storedFrequentProjects.shift(); // Remove an item from head of array
}
storedFrequentProjects.push({ ...project, frequency: 1 });
}
}
localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects));
}
}
getTopFrequentProjects() {
const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey));
let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP;
if (!storedFrequentProjects) {
return [];
}
if (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'xs') {
frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE;
}
const frequentProjects = storedFrequentProjects.filter(
project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY,
);
if (!frequentProjects || frequentProjects.length === 0) {
return [];
}
// Sort all frequent projects in decending order of frequency
// and then by lastAccessedOn with recent most first
frequentProjects.sort((projectA, projectB) => {
if (projectA.frequency < projectB.frequency) {
return 1;
} else if (projectA.frequency > projectB.frequency) {
return -1;
} else if (projectA.lastAccessedOn < projectB.lastAccessedOn) {
return 1;
} else if (projectA.lastAccessedOn > projectB.lastAccessedOn) {
return -1;
}
return 0;
});
return _.first(frequentProjects, frequentProjectsCount);
}
}
export default class ProjectsStore {
constructor() {
this.state = {};
this.state.frequentProjects = [];
this.state.searchedProjects = [];
}
setFrequentProjects(rawProjects) {
this.state.frequentProjects = rawProjects;
}
getFrequentProjects() {
return this.state.frequentProjects;
}
setSearchedProjects(rawProjects) {
this.state.searchedProjects = rawProjects.map(rawProject => ({
id: rawProject.id,
name: rawProject.name,
namespace: rawProject.name_with_namespace,
webUrl: rawProject.web_url,
avatarUrl: rawProject.avatar_url,
}));
}
getSearchedProjects() {
return this.state.searchedProjects;
}
clearSearchedProjects() {
this.state.searchedProjects = [];
}
}
......@@ -79,66 +79,62 @@ export default {
</script>
<template>
<div class="mr-widget-heading deploy-heading">
<div class="mr-widget-heading deploy-heading append-bottom-default">
<div class="ci-widget media">
<div class="ci-status-icon ci-status-icon-success">
<span class="js-icon-link icon-link">
<status-icon status="success" />
</span>
</div>
<div class="media-body">
<div class="deploy-body">
<template v-if="hasDeploymentMeta">
<span>
Deployed to
</span>
<a
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-meta"
<div class="deployment-info">
<template v-if="hasDeploymentMeta">
<span>
Deployed to
</span>
<a
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-meta"
>
{{ deployment.name }}
</a>
</template>
<span
v-tooltip
v-if="hasDeploymentTime"
:title="deployment.deployed_at_formatted"
class="js-deploy-time"
>
{{ deployment.name }}
</a>
</template>
<template v-if="hasExternalUrls">
<span>
on
{{ deployTimeago }}
</span>
<memory-usage
v-if="hasMetrics"
:metrics-url="deployment.metrics_url"
:metrics-monitoring-url="deployment.metrics_monitoring_url"
/>
</div>
<div>
<a
v-if="hasExternalUrls"
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-url"
class="deploy-link js-deploy-url btn btn-default btn-sm inline"
>
{{ deployment.external_url_formatted }}
<icon
:size="16"
name="external-link"
/>
<span>
View app
<icon name="external-link" />
</span>
</a>
</template>
<span
v-tooltip
v-if="hasDeploymentTime"
:title="deployment.deployed_at_formatted"
class="js-deploy-time"
>
{{ deployTimeago }}
</span>
<loading-button
v-if="deployment.stop_url"
:loading="isStopping"
container-class="btn btn-default btn-sm prepend-left-default"
label="Stop environment"
@click="stopEnvironment"
/>
<loading-button
v-if="deployment.stop_url"
:loading="isStopping"
container-class="btn btn-default btn-sm inline prepend-left-4"
title="Stop environment"
@click="stopEnvironment"
>
<icon name="stop" />
</loading-button>
</div>
</div>
<memory-usage
v-if="hasMetrics"
:metrics-url="deployment.metrics_url"
:metrics-monitoring-url="deployment.metrics_monitoring_url"
/>
</div>
</div>
</div>
......
......@@ -2,7 +2,7 @@
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 Icon from '~/vue_shared/components/icon.vue';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
......@@ -11,7 +11,7 @@ export default {
tooltip,
},
components: {
icon,
Icon,
clipboardButton,
},
props: {
......@@ -54,104 +54,114 @@ export default {
};
</script>
<template>
<div class="mr-source-target">
<div class="normal">
<strong>
{{ s__("mrWidget|Request to merge") }}
<span
:class="{ 'label-truncated': isSourceBranchLong }"
:title="isSourceBranchLong ? mr.sourceBranch : ''"
:v-tooltip="isSourceBranchLong"
class="label-branch js-source-branch"
data-placement="bottom"
v-html="mr.sourceBranchLink"
>
</span>
<div class="mr-source-target append-bottom-default">
<div class="git-merge-icon-container append-right-default">
<icon name="git-merge" />
</div>
<div class="git-merge-container d-flex">
<div class="normal">
<strong>
{{ s__("mrWidget|Request to merge") }}
<span
:class="{ 'label-truncated': isSourceBranchLong }"
:title="isSourceBranchLong ? mr.sourceBranch : ''"
:v-tooltip="isSourceBranchLong"
class="label-branch js-source-branch"
data-placement="bottom"
v-html="mr.sourceBranchLink"
>
</span>
<clipboard-button
:text="branchNameClipboardData"
:title="__('Copy branch name to clipboard')"
css-class="btn-default btn-transparent btn-clipboard"
/>
<clipboard-button
:text="branchNameClipboardData"
:title="__('Copy branch name to clipboard')"
css-class="btn-default btn-transparent btn-clipboard"
/>
{{ s__("mrWidget|into") }}
{{ s__("mrWidget|into") }}
<span
:v-tooltip="isTargetBranchLong"
:class="{ 'label-truncatedtooltip': isTargetBranchLong }"
:title="isTargetBranchLong ? mr.targetBranch : ''"
class="label-branch"
data-placement="bottom"
>
<a
:href="mr.targetBranchTreePath"
class="js-target-branch"
<span
:v-tooltip="isTargetBranchLong"
:class="{ 'label-truncatedtooltip': isTargetBranchLong }"
:title="isTargetBranchLong ? mr.targetBranch : ''"
class="label-branch"
data-placement="bottom"
>
{{ mr.targetBranch }}
</a>
</span>
</strong>
<span
v-if="shouldShowCommitsBehindText"
class="diverged-commits-count"
>
(<a :href="mr.targetBranchPath">{{ commitsText }}</a>)
</span>
</div>
<a
:href="mr.targetBranchTreePath"
class="js-target-branch"
>
{{ mr.targetBranch }}
</a>
</span>
</strong>
<div
v-if="shouldShowCommitsBehindText"
class="diverged-commits-count"
>
<span class="monospace">{{ mr.sourceBranch }}</span>
is {{ commitsText }}
<span class="monospace">{{ mr.targetBranch }}</span>
</div>
</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
:disabled="mr.sourceBranchRemoved"
data-target="#modal_merge_info"
data-toggle="modal"
class="btn btn-sm btn-default inline js-check-out-branch"
type="button"
<div
v-if="mr.isOpen"
class="branch-actions"
>
{{ s__("mrWidget|Check out branch") }}
</button>
<span class="dropdown prepend-left-10">
<a
v-if="!mr.sourceBranchRemoved"
:href="webIdePath"
class="btn btn-default inline js-web-ide d-none d-md-inline-block"
>
{{ s__("mrWidget|Open in Web IDE") }}
</a>
<button
:disabled="mr.sourceBranchRemoved"
data-target="#modal_merge_info"
data-toggle="modal"
class="btn btn-default inline js-check-out-branch"
type="button"
class="btn btn-sm inline dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
aria-haspopup="true"
aria-expanded="false"
>
<icon name="download" />
<i
class="fa fa-caret-down"
aria-hidden="true">
</i>
{{ s__("mrWidget|Check out branch") }}
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li>
<a
:href="mr.emailPatchesPath"
class="js-download-email-patches"
download
>
{{ s__("mrWidget|Email patches") }}
</a>
</li>
<li>
<a
:href="mr.plainDiffPath"
class="js-download-plain-diff"
download
>
{{ s__("mrWidget|Plain diff") }}
</a>
</li>
</ul>
</span>
<span class="dropdown prepend-left-10">
<button
type="button"
class="btn inline dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
aria-haspopup="true"
aria-expanded="false"
>
<icon name="download" />
<i
class="fa fa-caret-down"
aria-hidden="true">
</i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li>
<a
:href="mr.emailPatchesPath"
class="js-download-email-patches"
download
>
{{ s__("mrWidget|Email patches") }}
</a>
</li>
<li>
<a
:href="mr.plainDiffPath"
class="js-download-plain-diff"
download
>
{{ s__("mrWidget|Plain diff") }}
</a>
</li>
</ul>
</span>
</div>
</div>
</div>
</template>
......@@ -26,6 +26,10 @@ export default {
type: String,
required: false,
},
sourceBranchLink: {
type: String,
required: false,
},
},
computed: {
hasPipeline() {
......@@ -54,12 +58,18 @@ export default {
<template>
<div
v-if="hasPipeline || hasCIError"
class="mr-widget-heading"
class="mr-widget-heading append-bottom-default"
>
<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">
<icon name="status_failed" />
<div
class="add-border ci-status-icon ci-status-icon-failed ci-error
js-ci-error append-right-default"
>
<icon
:size="32"
name="status_failed_borderless"
/>
</div>
<div class="media-body">
Could not connect to the CI server. Please check your settings and try again
......@@ -68,50 +78,66 @@ export default {
<template v-else-if="hasPipeline">
<a
:href="status.details_path"
class="append-right-10"
class="align-self-start append-right-default"
>
<ci-icon :status="status" />
<ci-icon
:status="status"
:size="32"
:borderless="true"
class="add-border"
/>
</a>
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
<div class="media-body">
<div class="font-weight-bold">
Pipeline
<a
:href="pipeline.path"
class="pipeline-id font-weight-normal pipeline-number"
>#{{ pipeline.id }}</a>
<div class="media-body">
Pipeline
<a
:href="pipeline.path"
class="pipeline-id"
>
#{{ pipeline.id }}
</a>
{{ pipeline.details.status.label }}
{{ pipeline.details.status.label }}
<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
v-if="hasStages"
class="stage-cell"
>
<template v-if="hasCommitInfo">
for
<a
:href="pipeline.commit.commit_path"
class="commit-sha js-commit-link font-weight-normal"
>
{{ pipeline.commit.short_id }}</a>
on
<span
class="label-branch"
v-html="sourceBranchLink"
>
</span>
</template>
</div>
<div
v-for="(stage, i) in pipeline.details.stages"
:key="i"
class="stage-container dropdown js-mini-pipeline-graph"
v-if="pipeline.coverage"
class="coverage"
>
<pipeline-stage :stage="stage" />
Coverage {{ pipeline.coverage }}%
</div>
</div>
</div>
<div>
<span class="mr-widget-pipeline-graph">
<span
v-if="hasStages"
class="stage-cell"
>
<div
v-for="(stage, i) in pipeline.details.stages"
:key="i"
class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages"
>
<pipeline-stage :stage="stage" />
</div>
</span>
</span>
</span>
<template v-if="pipeline.coverage">
Coverage {{ pipeline.coverage }}%
</template>
</div>
</div>
</template>
</div>
......
......@@ -43,6 +43,7 @@
<ci-icon
v-else
:status="statusObj"
:size="24"
/>
<button
......
......@@ -252,41 +252,44 @@ export default {
:pipeline="mr.pipeline"
:ci-status="mr.ciStatus"
:has-ci="mr.hasCI"
:source-branch-link="mr.sourceBranchLink"
/>
<deployment
v-for="deployment in mr.deployments"
:key="deployment.id"
:deployment="deployment"
/>
<div class="mr-widget-section">
<component
:is="componentName"
:mr="mr"
:service="service"
/>
<div class="mr-section-container">
<div class="mr-widget-section">
<component
:is="componentName"
:mr="mr"
:service="service"
/>
<section
v-if="mr.allowCollaboration"
class="mr-info-list mr-links"
>
{{ s__("mrWidget|Allows commits from members who can merge to the target branch") }}
</section>
<section
v-if="mr.allowCollaboration"
class="mr-info-list mr-links"
>
{{ s__("mrWidget|Allows commits from members who can merge to the target branch") }}
</section>
<mr-widget-related-links
v-if="shouldRenderRelatedLinks"
:state="mr.state"
:related-links="mr.relatedLinks"
/>
<mr-widget-related-links
v-if="shouldRenderRelatedLinks"
:state="mr.state"
:related-links="mr.relatedLinks"
/>
<source-branch-removal-status
v-if="shouldRenderSourceBranchRemovalStatus"
/>
</div>
<div
v-if="shouldRenderMergeHelp"
class="mr-widget-footer"
>
<mr-widget-merge-help />
<source-branch-removal-status
v-if="shouldRenderSourceBranchRemovalStatus"
/>
</div>
<div
v-if="shouldRenderMergeHelp"
class="mr-widget-footer"
>
<mr-widget-merge-help />
</div>
</div>
</div>
</template>
<script>
import $ from 'jquery';
import { s__ } from '~/locale';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
import markdownHeader from './header.vue';
......@@ -22,6 +23,11 @@
type: String,
required: true,
},
markdownVersion: {
type: Number,
required: false,
default: 0,
},
addSpacingClasses: {
type: Boolean,
required: false,
......@@ -92,10 +98,11 @@
if (text) {
this.markdownPreviewLoading = true;
this.$http.post(this.markdownPreviewPath, { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
.catch(() => new Flash('Error loading markdown preview'));
this.$http
.post(this.versionedPreviewPath(), { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
.catch(() => new Flash(s__('Error loading markdown preview')));
} else {
this.renderMarkdown();
}
......@@ -119,6 +126,13 @@
$(this.$refs['markdown-preview']).renderGFM();
});
},
versionedPreviewPath() {
const { markdownPreviewPath, markdownVersion } = this;
return `${markdownPreviewPath}${
markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
}markdown_version=${markdownVersion}`;
},
},
};
</script>
......
......@@ -36,7 +36,7 @@
width: 100%;
}
&.projects-dropdown-menu {
&.frequent-items-dropdown-menu {
padding: 0;
overflow-y: initial;
max-height: initial;
......@@ -790,6 +790,7 @@
@include media-breakpoint-down(xs) {
.navbar-gitlab {
li.header-projects,
li.header-groups,
li.header-more,
li.header-new,
li.header-user {
......@@ -813,18 +814,18 @@
}
}
header.header-content .dropdown-menu.projects-dropdown-menu {
header.header-content .dropdown-menu.frequent-items-dropdown-menu {
padding: 0;
}
.projects-dropdown-container {
.frequent-items-dropdown-container {
display: flex;
flex-direction: row;
width: 500px;
height: 334px;
.project-dropdown-sidebar,
.project-dropdown-content {
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
padding: 8px 0;
}
......@@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
color: $almost-black;
}
.project-dropdown-sidebar {
.frequent-items-dropdown-sidebar {
width: 30%;
border-right: 1px solid $border-color;
}
.project-dropdown-content {
.frequent-items-dropdown-content {
position: relative;
width: 70%;
}
......@@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
height: auto;
flex: 1;
.project-dropdown-sidebar,
.project-dropdown-content {
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
width: 100%;
}
.project-dropdown-sidebar {
.frequent-items-dropdown-sidebar {
border-bottom: 1px solid $border-color;
border-right: 0;
}
}
.projects-list-frequent-container,
.projects-list-search-container {
.section-header,
.frequent-items-list-container li.section-empty {
padding: 0 $gl-padding;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
}
.frequent-items-list-container {
padding: 8px 0;
overflow-y: auto;
li.section-empty.section-failure {
color: $callout-danger-color;
}
}
.section-header,
.projects-list-frequent-container li.section-empty,
.projects-list-search-container li.section-empty {
padding: 0 15px;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
.frequent-items-list-item-container a {
display: flex;
}
}
.search-input-container {
......@@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
margin-top: 8px;
}
.projects-list-search-container {
.frequent-items-search-container {
height: 284px;
}
@include media-breakpoint-down(xs) {
.projects-list-frequent-container {
.frequent-items-list-container {
width: auto;
height: auto;
padding-bottom: 0;
......@@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
}
.projects-list-item-container {
.project-item-avatar-container .project-item-metadata-container {
.frequent-items-list-item-container {
.frequent-items-item-avatar-container,
.frequent-items-item-metadata-container {
float: left;
}
.project-title,
.project-namespace {
.frequent-items-item-metadata-container {
display: flex;
flex-direction: column;
justify-content: center;
}
.frequent-items-item-title,
.frequent-items-item-namespace {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
.project-item-avatar-container .avatar {
.frequent-items-item-avatar-container .avatar {
border-color: $md-area-border;
}
}
.project-title {
.frequent-items-item-title {
font-size: $gl-font-size;
font-weight: 400;
line-height: 16px;
}
.project-namespace {
.frequent-items-item-namespace {
margin-top: 4px;
font-size: 12px;
line-height: 12px;
......@@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
@include media-breakpoint-down(xs) {
.project-item-metadata-container {
.frequent-items-item-metadata-container {
float: none;
}
}
......
......@@ -255,3 +255,8 @@ label {
color: $theme-gray-600;
}
}
.input-lg {
max-width: 320px;
width: 100%;
}
......@@ -29,15 +29,21 @@
.navbar-sub-nav,
.navbar-nav {
> li {
> a:hover,
> a:focus {
background-color: rgba($search-and-nav-links, 0.2);
> a,
> button {
&:hover,
&:focus {
background-color: rgba($search-and-nav-links, 0.2);
}
}
&.active > a,
&.dropdown.show > a {
color: $nav-svg-color;
background-color: $color-alternate;
&.active,
&.dropdown.show {
> a,
> button {
color: $nav-svg-color;
background-color: $color-alternate;
}
}
&.line-separator {
......@@ -147,7 +153,6 @@
}
}
// Sidebar
.nav-sidebar li.active {
box-shadow: inset 4px 0 0 $border-and-box-shadow;
......
......@@ -269,14 +269,8 @@
.navbar-sub-nav,
.navbar-nav {
> li {
> a:hover,
> a:focus {
text-decoration: none;
outline: 0;
color: $white-light;
}
> a {
> a,
> button {
display: -webkit-flex;
display: flex;
align-items: center;
......@@ -288,6 +282,18 @@
border-radius: $border-radius-default;
height: 32px;
font-weight: $gl-font-weight-bold;
&:hover,
&:focus {
text-decoration: none;
outline: 0;
color: $white-light;
}
}
> button {
background: transparent;
border: 0;
}
&.line-separator {
......@@ -311,7 +317,7 @@
font-size: 10px;
}
.project-item-select-holder {
.frequent-items-item-select-holder {
display: inline;
}
......
......@@ -3,12 +3,20 @@
svg {
fill: $green-500;
}
&.add-border {
@include borderless-status-icon($green-500);
}
}
.ci-status-icon-failed {
svg {
fill: $gl-danger;
}
&.add-border {
@include borderless-status-icon($red-500);
}
}
.ci-status-icon-pending,
......@@ -17,12 +25,20 @@
svg {
fill: $orange-500;
}
&.add-border {
@include borderless-status-icon($orange-500);
}
}
.ci-status-icon-running {
svg {
fill: $blue-400;
}
&.add-border {
@include borderless-status-icon($blue-400);
}
}
.ci-status-icon-canceled,
......@@ -30,6 +46,10 @@
svg {
fill: $gl-text-color;
}
&.add-border {
@include borderless-status-icon($gl-text-color);
}
}
.ci-status-icon-created,
......@@ -38,6 +58,10 @@
svg {
fill: $gray-darkest;
}
&.add-border {
@include borderless-status-icon($gray-darkest);
}
}
.ci-status-icon-manual {
......
......@@ -232,3 +232,10 @@
word-break: break-word;
max-width: 100%;
}
@mixin borderless-status-icon($color) {
svg {
border: 1px solid $color;
border-radius: 50%;
}
}
......@@ -350,7 +350,8 @@ code {
}
.commit-sha,
.ref-name {
.ref-name,
.pipeline-number {
@extend .monospace;
font-size: 95%;
}
......
......@@ -743,6 +743,7 @@ Pipeline Graph
*/
$stage-hover-bg: $gray-darker;
$ci-action-icon-size: 22px;
$ci-action-icon-size-lg: 24px;
$pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
$ci-action-dropdown-button-size: 24px;
......
......@@ -80,7 +80,6 @@
overflow-x: scroll;
white-space: nowrap;
min-height: 200px;
display: flex;
@include media-breakpoint-only(sm) {
height: calc(100vh - #{$issue-board-list-difference-sm});
......@@ -111,15 +110,17 @@
.board {
display: inline-block;
flex: 1;
min-width: 300px;
max-width: 400px;
width: calc(85vw - 15px);
height: 100%;
padding-right: ($gl-padding / 2);
padding-left: ($gl-padding / 2);
white-space: normal;
vertical-align: top;
@include media-breakpoint-up(sm) {
width: 400px;
}
&.is-expandable {
.board-header {
cursor: pointer;
......@@ -127,8 +128,6 @@
}
&.is-collapsed {
flex: none;
min-width: 0;
width: 50px;
.board-header {
......
......@@ -15,16 +15,38 @@
}
}
.mr-widget-heading {
position: relative;
border: 1px solid $border-color;
border-radius: 4px;
&:not(.deploy-heading)::before {
content: '';
border-left: 1px solid $theme-gray-200;
position: absolute;
left: 32px;
top: -17px;
height: 16px;
}
}
.mr-section-container {
border: 1px solid $border-color;
border-radius: $border-radius-default;
border-top: 0;
}
.mr-widget-heading,
.mr-widget-section,
.mr-widget-footer {
padding: $gl-padding;
}
.mr-state-widget {
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: 2px;
line-height: 28px;
.mr-widget-heading,
.mr-widget-section,
.mr-widget-footer {
padding: $gl-padding;
border-top: solid 1px $border-color;
}
......@@ -124,10 +146,17 @@
.ci-widget {
color: $gl-text-color;
display: flex;
align-items: center;
justify-content: space-between;
@include media-breakpoint-down(xs) {
flex-wrap: wrap;
}
.ci-widget-content {
display: flex;
align-items: center;
}
}
.mr-widget-icon {
......@@ -136,8 +165,6 @@
}
.ci-status-icon svg {
width: $status-icon-size;
height: $status-icon-size;
margin: 3px 0;
position: relative;
overflow: visible;
......@@ -145,8 +172,6 @@
}
.mr-widget-pipeline-graph {
padding: 0 4px;
.dropdown-menu {
z-index: 300;
}
......@@ -157,7 +182,7 @@
}
.normal {
line-height: 28px;
flex: 1;
}
.capitalize {
......@@ -168,7 +193,7 @@
@extend .ref-name;
color: $gl-text-color;
font-weight: $gl-font-weight-bold;
font-weight: normal;
overflow: hidden;
word-break: break-all;
......@@ -192,6 +217,8 @@
}
.mr-widget-body {
line-height: 28px;
@include clearfix;
&.media > *:first-child {
......@@ -474,18 +501,66 @@
}
}
.merge-request-details .content-block {
border-bottom: 0;
}
.mr-source-target {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
background-color: $gray-light;
border-radius: $border-radius-default $border-radius-default 0 0;
padding: $gl-padding / 2 $gl-padding;
border-radius: $border-radius-default;
padding: $gl-padding;
border: 1px solid $border-color;
min-height: 69px;
@include media-breakpoint-up(md) {
align-items: center;
}
.dropdown-toggle .fa {
color: $gl-text-color;
}
.git-merge-icon-container {
border: 1px solid $theme-gray-400;
border-radius: 50%;
height: 32px;
width: 32px;
color: $theme-gray-700;
line-height: 28px;
.ic-git-merge {
vertical-align: middle;
width: 31px;
}
}
.git-merge-container {
justify-content: space-between;
flex: 1;
flex-direction: row;
align-items: center;
@include media-breakpoint-down(md) {
flex-direction: column;
align-items: flex-start;
.branch-actions {
margin-top: 16px;
}
}
@include media-breakpoint-up(lg) {
.branch-actions {
align-self: center;
}
}
}
.diverged-commits-count {
color: $gl-text-color-secondary;
font-size: 12px;
}
}
.card-new-merge-request {
......@@ -720,13 +795,25 @@
}
.deploy-heading {
margin-top: -19px;
border-top-left-radius: 0;
border-top-right-radius: 0;
background-color: $gray-light;
@include media-breakpoint-up(md) {
padding: $gl-padding-8 $gl-padding;
}
.media-body {
min-width: 0;
font-size: 12px;
margin-left: 48px;
}
}
.deploy-body {
display: flex;
align-items: center;
flex-wrap: wrap;
@include media-breakpoint-up(xs) {
......@@ -734,6 +821,15 @@
white-space: nowrap;
}
@include media-breakpoint-down(md) {
flex-direction: column;
align-items: flex-start;
.deployment-info {
margin-bottom: $gl-padding;
}
}
> *:not(:last-child) {
margin-right: .3em;
}
......@@ -741,18 +837,22 @@
svg {
vertical-align: text-top;
}
}
.deploy-link {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 100px;
max-width: 150px;
.deployment-info {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 100px;
@include media-breakpoint-up(xs) {
min-width: 0;
max-width: 100%;
@include media-breakpoint-up(xs) {
min-width: 0;
max-width: 100%;
}
}
.btn svg {
fill: $theme-gray-700;
}
}
......@@ -772,3 +872,33 @@
}
}
}
.ci-widget-container {
justify-content: space-between;
flex: 1;
flex-direction: row;
@include media-breakpoint-down(md) {
flex-direction: column;
.stage-cell .stage-container {
margin-top: 16px;
}
.dropdown .mini-pipeline-graph-dropdown-menu.dropdown-menu {
transform: initial;
}
}
.coverage {
font-size: 12px;
color: $theme-gray-700;
line-height: initial;
}
.mini-pipeline-graph-dropdown-toggle,
.stage-cell .mini-pipeline-graph-dropdown-toggle svg {
height: $ci-action-icon-size-lg;
width: $ci-action-icon-size-lg;
}
}
......@@ -301,6 +301,21 @@
border-bottom: 2px solid $border-color;
}
}
//delete when all pipelines are updated to new size
&.mr-widget-pipeline-stages {
+ .stage-container {
margin-left: 4px;
}
&:not(:last-child) {
&::after {
width: 4px;
right: -4px;
top: 11px;
}
}
}
}
}
......
......@@ -1329,3 +1329,14 @@
line-height: 16px;
color: $gl-text-color-secondary;
}
.ide-merge-request-info {
.detail-page-header {
line-height: initial;
min-height: 38px;
}
.issuable-details {
overflow: auto;
}
}
module GroupTree
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def render_group_tree(groups)
@groups = if params[:filter].present?
# We find the ancestors by ID of the search results here.
# Otherwise the ancestors would also have filters applied,
# which would cause them not to be preloaded.
group_ids = groups.search(params[:filter]).select(:id)
Gitlab::GroupHierarchy.new(Group.where(id: group_ids))
.base_and_ancestors
else
# Only show root groups if no parent-id is given
groups.where(parent_id: params[:parent_id])
end
groups = groups.sort_by_attribute(@sort = params[:sort])
@groups = @groups.with_selects_for_list(archived: params[:archived])
.sort_by_attribute(@sort = params[:sort])
.page(params[:page])
groups = if params[:filter].present?
filtered_groups_with_ancestors(groups)
else
# If `params[:parent_id]` is `nil`, we will only show root-groups
groups.where(parent_id: params[:parent_id]).page(params[:page])
end
@groups = groups.with_selects_for_list(archived: params[:archived])
respond_to do |format|
format.html
......@@ -28,4 +23,21 @@ module GroupTree
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
def filtered_groups_with_ancestors(groups)
filtered_groups = groups.search(params[:filter]).page(params[:page])
if Group.supports_nested_groups?
# We find the ancestors by ID of the search results here.
# Otherwise the ancestors would also have filters applied,
# which would cause them not to be preloaded.
#
# Pagination needs to be applied before loading the ancestors to
# make sure ancestors are not cut off by pagination.
Gitlab::GroupHierarchy.new(Group.where(id: filtered_groups.select(:id)))
.base_and_ancestors
else
filtered_groups
end
end
end
......@@ -14,6 +14,8 @@ module PreviewMarkdown
else {}
end
markdown_params[:markdown_engine] = result[:markdown_engine]
render json: {
body: view_context.markdown(result[:text], markdown_params),
references: {
......
......@@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
end
def labels
render json: @autocomplete_service.labels(target)
render json: @autocomplete_service.labels_as_hash(target)
end
def milestones
......
......@@ -2,6 +2,7 @@ class ProjectsController < Projects::ApplicationController
include IssuableCollections
include ExtractsPath
include PreviewMarkdown
include SendFileUpload
before_action :whitelist_query_limiting, only: [:create]
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
......@@ -188,9 +189,9 @@ class ProjectsController < Projects::ApplicationController
end
def download_export
export_project_path = @project.export_project_path
if export_project_path
if export_project_object_storage?
send_upload(@project.import_export_upload.export_file)
elsif export_project_path
send_file export_project_path, disposition: 'attachment'
else
redirect_to(
......@@ -265,8 +266,6 @@ class ProjectsController < Projects::ApplicationController
render json: options.to_json
end
private
# Render project landing depending of which features are available
# So if page is not availble in the list it renders the next page
#
......@@ -424,4 +423,12 @@ class ProjectsController < Projects::ApplicationController
def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440')
end
def export_project_path
@export_project_path ||= @project.export_project_path
end
def export_project_object_storage?
@project.export_project_object_exists?
end
end
......@@ -128,8 +128,10 @@ module GroupsHelper
def get_group_sidebar_links
links = [:overview, :group_members]
if can?(current_user, :read_cross_project)
links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests]
resources = [:activity, :issues, :boards, :labels, :milestones,
:merge_requests]
links += resources.select do |resource|
can?(current_user, "read_group_#{resource}".to_sym, @group)
end
if can?(current_user, :admin_group, @group)
......
......@@ -249,6 +249,7 @@ module IssuablesHelper
issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
markdownVersion: issuable.cached_markdown_version,
issuableTemplates: issuable_templates(issuable),
initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title,
......
......@@ -107,6 +107,7 @@ module MarkupHelper
def markup(file_name, text, context = {})
context[:project] ||= @project
context[:markdown_engine] ||= :redcarpet
html = context.delete(:rendered) || markup_unsafe(file_name, text, context)
prepare_for_rendering(html, context)
end
......@@ -120,7 +121,8 @@ module MarkupHelper
project: @project,
project_wiki: @project_wiki,
page_slug: wiki_page.slug,
issuable_state_filter_enabled: true
issuable_state_filter_enabled: true,
markdown_engine: :redcarpet
}
html =
......
......@@ -169,6 +169,7 @@ module NotesHelper
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
markdownDocsPath: help_page_path('user/markdown'),
markdownVersion: issuable.cached_markdown_version,
quickActionsDocsPath: help_page_path('user/project/quick_actions'),
closePath: close_issuable_path(issuable),
reopenPath: reopen_issuable_path(issuable),
......
......@@ -153,7 +153,7 @@ class NotifyPreview < ActionMailer::Preview
cleanup do
note = yield
Notify.public_send(method, user.id, note)
Notify.public_send(method, user.id, note) # rubocop:disable GitlabSecurity/PublicSend
end
end
......
module Ci
class BuildTraceChunk < ActiveRecord::Base
include FastDestroyAll
include ::Gitlab::ExclusiveLeaseHelpers
extend Gitlab::Ci::Model
belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
default_value_for :data_store, :redis
WriteError = Class.new(StandardError)
CHUNK_SIZE = 128.kilobytes
CHUNK_REDIS_TTL = 1.week
WRITE_LOCK_RETRY = 10
WRITE_LOCK_SLEEP = 0.01.seconds
WRITE_LOCK_TTL = 1.minute
# Note: The ordering of this enum is related to the precedence of persist store.
# The bottom item takes the higest precedence, and the top item takes the lowest precedence.
enum data_store: {
redis: 1,
db: 2
database: 2,
fog: 3
}
class << self
def redis_data_key(build_id, chunk_index)
"gitlab:ci:trace:#{build_id}:chunks:#{chunk_index}"
def all_stores
@all_stores ||= self.data_stores.keys
end
def redis_data_keys
redis.pluck(:build_id, :chunk_index).map do |data|
redis_data_key(data.first, data.second)
end
def persistable_store
# get first available store from the back of the list
all_stores.reverse.find { |store| get_store_class(store).available? }
end
def redis_delete_data(keys)
return if keys.empty?
Gitlab::Redis::SharedState.with do |redis|
redis.del(keys)
end
def get_store_class(store)
@stores ||= {}
@stores[store] ||= "Ci::BuildTraceChunks::#{store.capitalize}".constantize.new
end
##
# FastDestroyAll concerns
def begin_fast_destroy
redis_data_keys
all_stores.each_with_object({}) do |store, result|
relation = public_send(store) # rubocop:disable GitlabSecurity/PublicSend
keys = get_store_class(store).keys(relation)
result[store] = keys if keys.present?
end
end
##
# FastDestroyAll concerns
def finalize_fast_destroy(keys)
redis_delete_data(keys)
keys.each do |store, value|
get_store_class(store).delete_keys(value)
end
end
end
......@@ -66,10 +70,15 @@ module Ci
end
def append(new_data, offset)
raise ArgumentError, 'New data is missing' unless new_data
raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0
raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize)
set_data(data.byteslice(0, offset) + new_data)
in_lock(*lock_params) do # Write opetation is atomic
unsafe_set_data!(data.byteslice(0, offset) + new_data)
end
schedule_to_persist if full?
end
def size
......@@ -88,93 +97,63 @@ module Ci
(start_offset...end_offset)
end
def use_database!
in_lock do
break if db?
break unless size > 0
self.update!(raw_data: data, data_store: :db)
self.class.redis_delete_data([redis_data_key])
def persist_data!
in_lock(*lock_params) do # Write opetation is atomic
unsafe_persist_to!(self.class.persistable_store)
end
end
private
def get_data
if redis?
redis_data
elsif db?
raw_data
else
raise 'Unsupported data store'
end&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default
end
def set_data(value)
raise ArgumentError, 'too much data' if value.bytesize > CHUNK_SIZE
in_lock do
if redis?
redis_set_data(value)
elsif db?
self.raw_data = value
else
raise 'Unsupported data store'
end
def unsafe_persist_to!(new_store)
return if data_store == new_store.to_s
raise ArgumentError, 'Can not persist empty data' unless size > 0
@data = value
old_store_class = self.class.get_store_class(data_store)
save! if changed?
get_data.tap do |the_data|
self.raw_data = nil
self.data_store = new_store
unsafe_set_data!(the_data)
end
schedule_to_db if full?
end
def schedule_to_db
return if db?
Ci::BuildTraceChunkFlushWorker.perform_async(id)
old_store_class.delete_data(self)
end
def full?
size == CHUNK_SIZE
def get_data
self.class.get_store_class(data_store).data(self)&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default
rescue Excon::Error::NotFound
# If the data store is :fog and the file does not exist in the object storage, this method returns nil.
end
def redis_data
Gitlab::Redis::SharedState.with do |redis|
redis.get(redis_data_key)
end
end
def unsafe_set_data!(value)
raise ArgumentError, 'New data size exceeds chunk size' if value.bytesize > CHUNK_SIZE
def redis_set_data(data)
Gitlab::Redis::SharedState.with do |redis|
redis.set(redis_data_key, data, ex: CHUNK_REDIS_TTL)
end
end
self.class.get_store_class(data_store).set_data(self, value)
@data = value
def redis_data_key
self.class.redis_data_key(build_id, chunk_index)
save! if changed?
end
def in_lock
write_lock_key = "trace_write:#{build_id}:chunks:#{chunk_index}"
def schedule_to_persist
return if data_persisted?
lease = Gitlab::ExclusiveLease.new(write_lock_key, timeout: WRITE_LOCK_TTL)
retry_count = 0
Ci::BuildTraceChunkFlushWorker.perform_async(id)
end
until uuid = lease.try_obtain
# Keep trying until we obtain the lease. To prevent hammering Redis too
# much we'll wait for a bit between retries.
sleep(WRITE_LOCK_SLEEP)
break if WRITE_LOCK_RETRY < (retry_count += 1)
end
def data_persisted?
!redis?
end
raise WriteError, 'Failed to obtain write lock' unless uuid
def full?
size == CHUNK_SIZE
end
self.reload if self.persisted?
return yield
ensure
Gitlab::ExclusiveLease.cancel(write_lock_key, uuid)
def lock_params
["trace_write:#{build_id}:chunks:#{chunk_index}",
{ ttl: WRITE_LOCK_TTL,
retries: WRITE_LOCK_RETRY,
sleep_sec: WRITE_LOCK_SLEEP }]
end
end
end
module Ci
module BuildTraceChunks
class Database
def available?
true
end
def keys(relation)
[]
end
def delete_keys(keys)
# no-op
end
def data(model)
model.raw_data
end
def set_data(model, data)
model.raw_data = data
end
def delete_data(model)
model.update_columns(raw_data: nil) unless model.raw_data.nil?
end
end
end
end
module Ci
module BuildTraceChunks
class Fog
def available?
object_store.enabled
end
def data(model)
connection.get_object(bucket_name, key(model))[:body]
end
def set_data(model, data)
connection.put_object(bucket_name, key(model), data)
end
def delete_data(model)
delete_keys([[model.build_id, model.chunk_index]])
end
def keys(relation)
return [] unless available?
relation.pluck(:build_id, :chunk_index)
end
def delete_keys(keys)
keys.each do |key|
connection.delete_object(bucket_name, key_raw(*key))
end
end
private
def key(model)
key_raw(model.build_id, model.chunk_index)
end
def key_raw(build_id, chunk_index)
"tmp/builds/#{build_id.to_i}/chunks/#{chunk_index.to_i}.log"
end
def bucket_name
return unless available?
object_store.remote_directory
end
def connection
return unless available?
@connection ||= ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys)
end
def object_store
Gitlab.config.artifacts.object_store
end
end
end
end
module Ci
module BuildTraceChunks
class Redis
CHUNK_REDIS_TTL = 1.week
def available?
true
end
def data(model)
Gitlab::Redis::SharedState.with do |redis|
redis.get(key(model))
end
end
def set_data(model, data)
Gitlab::Redis::SharedState.with do |redis|
redis.set(key(model), data, ex: CHUNK_REDIS_TTL)
end
end
def delete_data(model)
delete_keys([[model.build_id, model.chunk_index]])
end
def keys(relation)
relation.pluck(:build_id, :chunk_index)
end
def delete_keys(keys)
return if keys.empty?
keys = keys.map { |key| key_raw(*key) }
Gitlab::Redis::SharedState.with do |redis|
redis.del(keys)
end
end
private
def key(model)
key_raw(model.build_id, model.chunk_index)
end
def key_raw(build_id, chunk_index)
"gitlab:ci:trace:#{build_id.to_i}:chunks:#{chunk_index.to_i}"
end
end
end
end
......@@ -40,6 +40,18 @@ module CacheMarkdownField
end
end
class MarkdownEngine
def self.from_version(version = nil)
return :common_mark if version.nil? || version == 0
if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
:redcarpet
else
:common_mark
end
end
end
def skip_project_check?
false
end
......@@ -57,7 +69,7 @@ module CacheMarkdownField
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
context[:markdown_engine] = markdown_engine
context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version)
context
end
......@@ -123,14 +135,6 @@ module CacheMarkdownField
end
end
def markdown_engine
if latest_cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
:redcarpet
else
:common_mark
end
end
included do
cattr_reader :cached_markdown_fields do
FieldData.new
......
......@@ -7,7 +7,7 @@ module CacheableAttributes
class_methods do
def cache_key
"#{name}:#{Gitlab::VERSION}:#{Gitlab.migrations_hash}:#{Rails.version}".freeze
"#{name}:#{Gitlab::VERSION}:#{Rails.version}".freeze
end
# Can be overriden
......@@ -69,6 +69,6 @@ module CacheableAttributes
end
def cache!
Rails.cache.write(self.class.cache_key, self)
Rails.cache.write(self.class.cache_key, self, expires_in: 1.minute)
end
end
......@@ -44,8 +44,8 @@ module GroupDescendant
This error is not user facing, but causes a +1 query.
MSG
extras = {
parent: parent,
child: child,
parent: parent.inspect,
child: child.inspect,
preloaded: preloaded.map(&:full_path)
}
issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785'
......
class ImportExportUpload < ActiveRecord::Base
include WithUploads
include ObjectStorage::BackgroundMove
belongs_to :project
mount_uploader :import_file, ImportExportUploader
mount_uploader :export_file, ImportExportUploader
def retrieve_upload(_identifier, paths)
Upload.find_by(model: self, path: paths)
end
end
......@@ -131,9 +131,10 @@ class Milestone < ActiveRecord::Base
rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id')
else
rel
.group(:project_id)
.group(:project_id, :due_date, :id)
.having('due_date = MIN(due_date)')
.pluck(:id, :project_id, :due_date)
.uniq(&:second)
.map(&:first)
end
end
......
......@@ -171,6 +171,7 @@ class Project < ActiveRecord::Base
has_one :fork_network, through: :fork_network_member
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id'
......@@ -1712,7 +1713,7 @@ class Project < ActiveRecord::Base
:started
elsif after_export_in_progress?
:after_export_action
elsif export_project_path
elsif export_project_path || export_project_object_exists?
:finished
else
:none
......@@ -1727,16 +1728,21 @@ class Project < ActiveRecord::Base
import_export_shared.after_export_in_progress?
end
def remove_exports
return nil unless export_path.present?
FileUtils.rm_rf(export_path)
def remove_exports(path = export_path)
if path.present?
FileUtils.rm_rf(path)
elsif export_project_object_exists?
import_export_upload.remove_export_file!
import_export_upload.save
end
end
def remove_exported_project_file
return unless export_project_path.present?
remove_exports(export_project_path)
end
FileUtils.rm_f(export_project_path)
def export_project_object_exists?
Gitlab::ImportExport.object_storage? && import_export_upload&.export_file&.file
end
def full_path_slug
......
......@@ -83,7 +83,7 @@ class Repository
@raw_repository&.cleanup
end
# Return absolute path to repository
# Don't use this! It's going away. Use Gitaly to read or write from repos.
def path_to_repo
@path_to_repo ||=
begin
......@@ -250,7 +250,7 @@ class Repository
# This will still fail if the file is corrupted (e.g. 0 bytes)
raw_repository.write_ref(keep_around_ref_name(sha), sha, shell: false)
rescue Gitlab::Git::CommandError => ex
Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}"
end
def kept_around?(sha)
......@@ -564,7 +564,7 @@ class Repository
end
def rendered_readme
MarkupHelper.markup_unsafe(readme.name, readme.data, project: project) if readme
MarkupHelper.markup_unsafe(readme.name, readme.data, project: project, markdown_engine: :redcarpet) if readme
end
cache_method :rendered_readme
......
......@@ -72,6 +72,19 @@ class GroupPolicy < BasePolicy
enable :change_visibility_level
end
rule { can?(:read_nested_project_resources) }.policy do
enable :read_group_activity
enable :read_group_issues
enable :read_group_boards
enable :read_group_labels
enable :read_group_milestones
enable :read_group_merge_requests
end
rule { can?(:read_cross_project) & can?(:read_group) }.policy do
enable :read_nested_project_resources
end
rule { owner & nested_groups_supported }.enable :create_subgroup
rule { public_group | logged_in_viewable }.enable :view_globally
......
......@@ -62,6 +62,8 @@ class NoteEntity < API::Entities::Note
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
expose :cached_markdown_version
private
def current_user
......
......@@ -10,7 +10,9 @@ class ImportExportCleanUpService
def execute
Gitlab::Metrics.measure(:import_export_clean_up) do
next unless File.directory?(path)
clean_up_export_object_files
break unless File.directory?(path)
clean_up_export_files
end
......@@ -21,4 +23,11 @@ class ImportExportCleanUpService
def clean_up_export_files
Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete))
end
def clean_up_export_object_files
ImportExportUpload.where('updated_at < ?', mmin.minutes.ago).each do |upload|
upload.remove_export_file!
upload.save!
end
end
end
......@@ -135,6 +135,8 @@ class NotificationService
# * watchers of the mr's labels
# * users with custom level checked with "new merge request"
#
# In EE, approvers of the merge request are also included
#
def new_merge_request(merge_request, current_user)
new_resource_email(merge_request, :new_merge_request_email)
end
......@@ -256,6 +258,10 @@ class NotificationService
# ignore gitlab service messages
return true if note.cross_reference? && note.system?
send_new_note_notifications(note)
end
def send_new_note_notifications(note)
notify_method = "note_#{note.to_ability_name}_email".to_sym
recipients = NotificationRecipientService.build_new_note_recipients(note)
......
......@@ -6,7 +6,8 @@ class PreviewMarkdownService < BaseService
success(
text: text,
users: users,
commands: commands.join(' ')
commands: commands.join(' '),
markdown_engine: markdown_engine
)
end
......@@ -42,4 +43,8 @@ class PreviewMarkdownService < BaseService
def commands_target_id
params[:quick_actions_target_id]
end
def markdown_engine
CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i)
end
end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment