Commit 37a7b099 authored by Kamil Trzcinski's avatar Kamil Trzcinski

Merge remote-tracking branch 'origin/master' into optimise-pipelines

parents 57c353fc 73cb71e4
......@@ -30,6 +30,7 @@ eslint-report.html
/config/unicorn.rb
/config/secrets.yml
/config/sidekiq.yml
/config/registry.key
/coverage/*
/coverage-javascript/
/db/*.sqlite3
......
......@@ -2,6 +2,31 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 9.0.4 (2017-04-05)
- Don’t show source project name when user does not have access.
- Remove the class attribute from the whitelist for HTML generated from Markdown.
- Fix path disclosure in project import/export.
- Fix for open redirect vulnerability using continue[to] in URL when requesting project import status.
- Fix for open redirect vulnerabilities in todos, issues, and MR controllers.
## 9.0.3 (2017-04-05)
- Fix name colision when importing GitHub pull requests from forked repositories. !9719
- Fix GitHub Importer for PRs of deleted forked repositories. !9992
- Fix environment folder route when special chars present in environment name. !10250
- Improve Markdown rendering when a lot of merge requests are referenced. !10252
- Allow users to import GitHub projects to subgroups.
- Backport API changes needed to fix sticking in EE.
- Remove unnecessary ORDER BY clause from `forked_to_project_id` subquery. (mhasbini)
- Make CI build to use optimistic locking only on status change.
- Fix race condition where a namespace would be deleted before a project was deleted.
- Fix linking to new issue with selected template via url parameter.
- Remove unnecessary ORDER BY clause when updating todos. (mhasbini)
- API: Make the /notes endpoint work with noteable iid instead of id.
- Fixes method not replacing URL parameters correctly and breaking pipelines pagination.
- Move issue, mr, todos next to profile dropdown in top nav.
## 9.0.2 (2017-03-29)
- Correctly update paths when changing a child group.
......@@ -303,6 +328,14 @@ entry.
- Change development tanuki favicon colors to match logo color order.
- API issues - support filtering by iids.
## 8.17.5 (2017-04-05)
- Don’t show source project name when user does not have access.
- Remove the class attribute from the whitelist for HTML generated from Markdown.
- Fix path disclosure in project import/export.
- Fix for open redirect vulnerability using continue[to] in URL when requesting project import status.
- Fix for open redirect vulnerabilities in todos, issues, and MR controllers.
## 8.17.4 (2017-03-19)
- Only show public emails in atom feeds.
......@@ -516,6 +549,14 @@ entry.
- Remove deprecated GitlabCiService.
- Requeue pending deletion projects.
## 8.16.9 (2017-04-05)
- Don’t show source project name when user does not have access.
- Remove the class attribute from the whitelist for HTML generated from Markdown.
- Fix path disclosure in project import/export.
- Fix for open redirect vulnerability using continue[to] in URL when requesting project import status.
- Fix for open redirect vulnerabilities in todos, issues, and MR controllers.
## 8.16.8 (2017-03-19)
- Only show public emails in atom feeds.
......
......@@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4'
gem 'redis-namespace', '~> 1.5.2'
gem 'sidekiq-limit_fetch', '~> 3.4'
# Cron Parser
gem 'rufus-scheduler', '~> 3.1.10'
# HTTP requests
gem 'httparty', '~> 0.13.3'
......
......@@ -987,6 +987,7 @@ DEPENDENCIES
rubocop-rspec (~> 1.12.0)
ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.16.2)
rufus-scheduler (~> 3.1.10)
rugged (~> 0.25.1.1)
sanitize (~> 2.0)
sass-rails (~> 5.0.6)
......
......@@ -33,7 +33,7 @@ core team members will mention this person.
### Merge request coaching
Several people from the [GitLab team][team] are helping community members to get
their contributions accepted by meeting our [Definition of done](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done).
their contributions accepted by meeting our [Definition of done][done].
What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/.
......@@ -64,6 +64,49 @@ Merge requests may still be merged into master during this period,
but they will go into the _next_ release, unless they are manually cherry-picked into the stable branch.
By freezing the stable branches 2 weeks prior to a release, we reduce the risk of a last minute merge request potentially breaking things.
### Between the 1st and the 7th
These types of merge requests need special consideration:
* **Large features**: a large feature is one that is highlighted in the kick-off
and the release blogpost; typically this will have its own channel in Slack
and a dedicated team with front-end, back-end, and UX.
* **Small features**: any other feature request.
**Large features** must be with a maintainer **by the 1st**. It's OK if they
aren't completely done, but this allows the maintainer enough time to make the
decision about whether this can make it in before the freeze. If the maintainer
doesn't think it will make it, they should inform the developers working on it
and the Product Manager responsible for the feature.
**Small features** must be with a reviewer (not necessarily maintainer) **by the
3rd**.
Most merge requests from the community do not have a specific release
target. However, if one does and falls into either of the above categories, it's
the reviewer's responsibility to manage the above communication and assignment
on behalf of the community member.
### On the 7th
Merge requests should still be complete, following the
[definition of done][done]. The single exception is documentation, and this can
only be left until after the freeze if:
* There is a follow-up issue to add documentation.
* It is assigned to the person writing documentation for this feature, and they
are aware of it.
* It is in the correct milestone, with the ~Deliverable label.
All Community Edition merge requests from GitLab team members merged on the
freeze date (the 7th) should have a corresponding Enterprise Edition merge
request, even if there are no conflicts. This is to reduce the size of the
subsequent EE merge, as we often merge a lot to CE on the release date. For more
information, see
[limit conflicts with EE when developing on CE][limit_ee_conflicts].
### Between the 7th and the 22nd
Once the stable branch is frozen, only fixes for regressions (bugs introduced in that same release)
and security issues will be cherry-picked into the stable branch.
Any merge requests cherry-picked into the stable branch for a previous release will also be picked into the latest stable branch.
......@@ -158,3 +201,5 @@ still an issue I encourage you to open it on the [GitLab.com issue tracker](http
[contribution acceptance criteria]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#contribution-acceptance-criteria
["Implement design & UI elements" guidelines]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#implement-design-ui-elements
[Thoughtbot code review guide]: https://github.com/thoughtbot/guides/tree/master/code-review
[done]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done
[limit_ee_conflicts]: https://docs.gitlab.com/ce/development/limit_ee_conflicts.html
......@@ -263,7 +263,8 @@ AwardsHandler.prototype.addAward = function addAward(
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined;
});
return $('.emoji-menu').removeClass('is-visible');
$('.emoji-menu').removeClass('is-visible');
$('.js-add-award.is-active').removeClass('is-active');
};
AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar(
......@@ -476,10 +477,10 @@ AwardsHandler.prototype.setupSearch = function setupSearch() {
this.registerEventListener('on', $('input.emoji-search'), 'input', (e) => {
const term = $(e.target).val().trim();
// Clean previous search results
$('ul.emoji-menu-search, h5.emoji-search').remove();
$('ul.emoji-menu-search, h5.emoji-search-title').remove();
if (term.length > 0) {
// Generate a search result block
const h5 = $('<h5 class="emoji-search" />').text('Search results');
const h5 = $('<h5 class="emoji-search-title"/>').text('Search results');
const foundEmojis = this.searchEmojis(term).show();
const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
$('.emoji-menu-content ul, .emoji-menu-content h5').hide();
......
import * as THREE from 'three/build/three.module';
import STLLoaderClass from 'three-stl-loader';
import OrbitControlsClass from 'three-orbit-controls';
import MeshObject from './mesh_object';
const STLLoader = STLLoaderClass(THREE);
const OrbitControls = OrbitControlsClass(THREE);
export default class Renderer {
constructor(container) {
this.renderWrapper = this.render.bind(this);
this.objects = [];
this.container = container;
this.width = this.container.offsetWidth;
this.height = 500;
this.loader = new STLLoader();
this.fov = 45;
this.camera = new THREE.PerspectiveCamera(
this.fov,
this.width / this.height,
1,
1000,
);
this.scene = new THREE.Scene();
this.scene.add(this.camera);
// Setup the viewer
this.setupRenderer();
this.setupGrid();
this.setupLight();
// Setup OrbitControls
this.controls = new OrbitControls(
this.camera,
this.renderer.domElement,
);
this.controls.minDistance = 5;
this.controls.maxDistance = 30;
this.controls.enableKeys = false;
this.loadFile();
}
setupRenderer() {
this.renderer = new THREE.WebGLRenderer({
antialias: true,
});
this.renderer.setClearColor(0xFFFFFF);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(
this.width,
this.height,
);
}
setupLight() {
// Point light illuminates the object
const pointLight = new THREE.PointLight(
0xFFFFFF,
2,
0,
);
pointLight.castShadow = true;
this.camera.add(pointLight);
// Ambient light illuminates the scene
const ambientLight = new THREE.AmbientLight(
0xFFFFFF,
1,
);
this.scene.add(ambientLight);
}
setupGrid() {
this.grid = new THREE.GridHelper(
20,
20,
0x000000,
0x000000,
);
this.scene.add(this.grid);
}
loadFile() {
this.loader.load(this.container.dataset.endpoint, (geo) => {
const obj = new MeshObject(geo);
this.objects.push(obj);
this.scene.add(obj);
this.start();
this.setDefaultCameraPosition();
});
}
start() {
// Empty the container first
this.container.innerHTML = '';
// Add to DOM
this.container.appendChild(this.renderer.domElement);
// Make controls visible
this.container.parentNode.classList.remove('is-stl-loading');
this.render();
}
render() {
this.renderer.render(
this.scene,
this.camera,
);
requestAnimationFrame(this.renderWrapper);
}
changeObjectMaterials(type) {
this.objects.forEach((obj) => {
obj.changeMaterial(type);
});
}
setDefaultCameraPosition() {
const obj = this.objects[0];
const radius = (obj.geometry.boundingSphere.radius / 1.5);
const dist = radius / (Math.sin((this.fov * (Math.PI / 180)) / 2));
this.camera.position.set(
0,
dist + 1,
dist,
);
this.camera.lookAt(this.grid);
this.controls.update();
}
}
import {
Matrix4,
MeshLambertMaterial,
Mesh,
} from 'three/build/three.module';
const defaultColor = 0xE24329;
const materials = {
default: new MeshLambertMaterial({
color: defaultColor,
}),
wireframe: new MeshLambertMaterial({
color: defaultColor,
wireframe: true,
}),
};
export default class MeshObject extends Mesh {
constructor(geo) {
super(
geo,
materials.default,
);
this.geometry.computeBoundingSphere();
this.rotation.set(-Math.PI / 2, 0, 0);
if (this.geometry.boundingSphere.radius > 4) {
const scale = 4 / this.geometry.boundingSphere.radius;
this.geometry.applyMatrix(
new Matrix4().makeScale(
scale,
scale,
scale,
),
);
this.geometry.computeBoundingSphere();
this.position.x = -this.geometry.boundingSphere.center.x;
this.position.z = this.geometry.boundingSphere.center.y;
}
}
changeMaterial(type) {
this.material = materials[type];
}
}
function BlobForkSuggestion(openButton, cancelButton, suggestionSection) {
if (openButton) {
openButton.addEventListener('click', () => {
suggestionSection.classList.remove('hidden');
});
}
if (cancelButton) {
cancelButton.addEventListener('click', () => {
suggestionSection.classList.add('hidden');
});
}
}
export default BlobForkSuggestion;
/* eslint-disable no-new */
import Vue from 'vue';
import PDFLab from 'vendor/pdflab';
import workerSrc from 'vendor/pdf.worker';
Vue.use(PDFLab, {
workerSrc,
});
export default () => {
const el = document.getElementById('js-pdf-viewer');
return new Vue({
el,
data() {
return {
error: false,
loadError: false,
loading: true,
pdf: el.dataset.endpoint,
};
},
methods: {
onLoad() {
this.loading = false;
},
onError(error) {
this.loading = false;
this.loadError = true;
this.error = error;
},
},
template: `
<div class="container-fluid md prepend-top-default append-bottom-default">
<div
class="text-center loading"
v-if="loading && !error">
<i
class="fa fa-spinner fa-spin"
aria-hidden="true"
aria-label="PDF loading">
</i>
</div>
<pdf-lab
v-if="!loadError"
:pdf="pdf"
@pdflabload="onLoad"
@pdflaberror="onError" />
<p
class="text-center"
v-if="error">
<span v-if="loadError">
An error occured whilst loading the file. Please try again later.
</span>
<span v-else>
An error occured whilst decoding the file.
</span>
</p>
</div>
`,
});
};
import renderPDF from './pdf';
document.addEventListener('DOMContentLoaded', renderPDF);
import JSZip from 'jszip';
import JSZipUtils from 'jszip-utils';
export default class SketchLoader {
constructor(container) {
this.container = container;
this.loadingIcon = this.container.querySelector('.js-loading-icon');
this.load();
}
load() {
return this.getZipFile()
.then(data => JSZip.loadAsync(data))
.then(asyncResult => asyncResult.files['previews/preview.png'].async('uint8array'))
.then((content) => {
const url = window.URL || window.webkitURL;
const blob = new Blob([new Uint8Array(content)], {
type: 'image/png',
});
const previewUrl = url.createObjectURL(blob);
this.render(previewUrl);
})
.catch(this.error.bind(this));
}
getZipFile() {
return new JSZip.external.Promise((resolve, reject) => {
JSZipUtils.getBinaryContent(this.container.dataset.endpoint, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
render(previewUrl) {
const previewLink = document.createElement('a');
const previewImage = document.createElement('img');
previewLink.href = previewUrl;
previewLink.target = '_blank';
previewImage.src = previewUrl;
previewImage.className = 'img-responsive';
previewLink.appendChild(previewImage);
this.container.appendChild(previewLink);
this.removeLoadingIcon();
}
error() {
const errorMsg = document.createElement('p');
errorMsg.className = 'prepend-top-default append-bottom-default text-center';
errorMsg.textContent = `
Cannot show preview. For previews on sketch files, they must have the file format
introduced by Sketch version 43 and above.
`;
this.container.appendChild(errorMsg);
this.removeLoadingIcon();
}
removeLoadingIcon() {
if (this.loadingIcon) {
this.loadingIcon.remove();
}
}
}
/* eslint-disable no-new */
import SketchLoader from './sketch';
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-sketch-viewer');
new SketchLoader(el);
});
import Renderer from './3d_viewer';
document.addEventListener('DOMContentLoaded', () => {
const viewer = new Renderer(document.getElementById('js-stl-viewer'));
[].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => {
el.addEventListener('click', (e) => {
const target = e.target;
e.preventDefault();
document.querySelector('.js-material-changer.active').classList.remove('active');
target.classList.add('active');
target.blur();
viewer.changeObjectMaterials(target.dataset.type);
});
});
});
/* eslint-disable comma-dangle, space-before-function-paren, one-var */
/* global Sortable */
import Vue from 'vue';
import boardList from './board_list';
import boardBlankState from './board_blank_state';
require('./board_delete');
......@@ -16,7 +16,7 @@ require('./board_list');
gl.issueBoards.Board = Vue.extend({
template: '#js-board-template',
components: {
'board-list': gl.issueBoards.BoardList,
boardList,
'board-delete': gl.issueBoards.BoardDelete,
boardBlankState,
},
......
/* eslint-disable comma-dangle, space-before-function-paren, max-len */
/* global Sortable */
import Vue from 'vue';
import boardNewIssue from './board_new_issue';
import boardCard from './board_card';
import eventHub from '../eventhub';
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
const Store = gl.issueBoards.BoardsStore;
gl.issueBoards.BoardList = Vue.extend({
template: '#js-board-list-template',
components: {
boardCard,
boardNewIssue,
export default {
name: 'BoardList',
props: {
disabled: {
type: Boolean,
required: true,
},
props: {
disabled: Boolean,
list: Object,
issues: Array,
loading: Boolean,
issueLinkBase: String,
rootPath: String,
list: {
type: Object,
required: true,
},
data () {
return {
scrollOffset: 250,
filters: Store.state.filters,
showCount: false,
showIssueForm: false
};
issues: {
type: Array,
required: true,
},
watch: {
filters: {
handler () {
this.list.loadingMore = false;
this.$refs.list.scrollTop = 0;
},
deep: true
},
issues () {
this.$nextTick(() => {
if (this.scrollHeight() <= this.listHeight() && this.list.issuesSize > this.list.issues.length) {
this.list.page += 1;
this.list.getIssues(false);
}
loading: {
type: Boolean,
required: true,
},
issueLinkBase: {
type: String,
required: true,
},
rootPath: {
type: String,
required: true,
},
},
data() {
return {
scrollOffset: 250,
filters: Store.state.filters,
showCount: false,
showIssueForm: false,
};
},
components: {
boardCard,
boardNewIssue,
},
methods: {
listHeight() {
return this.$refs.list.getBoundingClientRect().height;
},
scrollHeight() {
return this.$refs.list.scrollHeight;
},
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
loadNextPage() {
const getIssues = this.list.nextPage();
if (this.scrollHeight() > Math.ceil(this.listHeight())) {
this.showCount = true;
} else {
this.showCount = false;
}
if (getIssues) {
this.list.loadingMore = true;
getIssues.then(() => {
this.list.loadingMore = false;
});
}
},
methods: {
listHeight () {
return this.$refs.list.getBoundingClientRect().height;
},
scrollHeight () {
return this.$refs.list.scrollHeight;
},
scrollTop () {
return this.$refs.list.scrollTop + this.listHeight();
toggleForm() {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
this.loadNextPage();
}
},
},
watch: {
filters: {
handler() {
this.list.loadingMore = false;
this.$refs.list.scrollTop = 0;
},
loadNextPage () {
const getIssues = this.list.nextPage();
deep: true,
},
issues() {
this.$nextTick(() => {
if (this.scrollHeight() <= this.listHeight() &&
this.list.issuesSize > this.list.issues.length) {
this.list.page += 1;
this.list.getIssues(false);
}
if (getIssues) {
this.list.loadingMore = true;
getIssues.then(() => {
this.list.loadingMore = false;
});
if (this.scrollHeight() > Math.ceil(this.listHeight())) {
this.showCount = true;
} else {
this.showCount = false;
}
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
},
},
created() {
gl.IssueBoardsApp.$on(`hide-issue-form-${this.list.id}`, this.toggleForm);
});
},
mounted () {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
scroll: document.querySelectorAll('.boards-list')[0],
group: 'issues',
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
onStart: (e) => {
const card = this.$refs.issue[e.oldIndex];
},
created() {
eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm);
},
mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
scroll: document.querySelectorAll('.boards-list')[0],
group: 'issues',
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
onStart: (e) => {
const card = this.$refs.issue[e.oldIndex];
card.showDetail = false;
Store.moving.list = card.list;
Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId);
card.showDetail = false;
Store.moving.list = card.list;
Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId);
gl.issueBoards.onStart();
},
onAdd: (e) => {
gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex);
gl.issueBoards.onStart();
},
onAdd: (e) => {
gl.issueBoards.BoardsStore
.moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex);
this.$nextTick(() => {
e.item.remove();
});
},
onUpdate: (e) => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
gl.issueBoards.BoardsStore.moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
},
onMove(e) {
return !e.related.classList.contains('board-list-count');
}
});
this.$nextTick(() => {
e.item.remove();
});
},
onUpdate: (e) => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
gl.issueBoards.BoardsStore
.moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
},
onMove(e) {
return !e.related.classList.contains('board-list-count');
},
});
this.sortable = Sortable.create(this.$refs.list, options);
this.sortable = Sortable.create(this.$refs.list, options);
// Scroll event on list to load more
this.$refs.list.onscroll = () => {
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
this.loadNextPage();
}
};
},
beforeDestroy() {
gl.IssueBoardsApp.$off(`hide-issue-form-${this.list.id}`, this.toggleForm);
},
});
})();
// Scroll event on list to load more
this.$refs.list.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
template: `
<div class="board-list-component">
<div
class="board-list-loading text-center"
aria-label="Loading issues"
v-if="loading">
<i
class="fa fa-spinner fa-spin"
aria-hidden="true">
</i>
</div>
<board-new-issue
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
<ul
class="board-list"
v-show="!loading"
ref="list"
:data-board="list.id"
:class="{ 'is-smaller': showIssueForm }">
<board-card
v-for="(issue, index) in issues"
ref="issue"
:index="index"
:list="list"
:issue="issue"
:issue-link-base="issueLinkBase"
:root-path="rootPath"
:disabled="disabled"
:key="issue.id" />
<li
class="board-list-count text-center"
v-if="showCount"
data-id="-1">
<i
class="fa fa-spinner fa-spin"
aria-label="Loading more issues"
aria-hidden="true"
v-show="list.loadingMore">
</i>
<span v-if="list.issues.length === list.issuesSize">
Showing all issues
</span>
<span v-else>
Showing {{ list.issues.length }} of {{ list.issuesSize }} issues
</span>
</li>
</ul>
</div>
`,
};
/* global ListIssue */
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
export default {
......@@ -49,7 +51,7 @@ export default {
},
cancel() {
this.title = '';
gl.IssueBoardsApp.$emit(`hide-issue-form-${this.list.id}`);
eventHub.$emit(`hide-issue-form-${this.list.id}`);
},
},
mounted() {
......
......@@ -84,10 +84,11 @@ window.Build = (function() {
var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'];
return $.ajax({
url: this.buildUrl,
url: this.pageUrl + "/trace.json",
dataType: 'json',
success: function(buildData) {
$('.js-build-output').html(buildData.trace_html);
$('.js-build-output').html(buildData.html);
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
if (window.location.hash === DOWN_BUILD_TRACE) {
$("html,body").scrollTop(this.$buildTrace.height());
}
......
......@@ -43,6 +43,7 @@ import GroupsList from './groups_list';
import ProjectsList from './projects_list';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout';
const ShortcutsBlob = require('./shortcuts_blob');
......@@ -86,6 +87,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
skipResetBindings: true,
fileBlobPermalinkUrl,
});
new BlobForkSuggestion(
document.querySelector('.js-edit-blob-link-fork-toggler'),
document.querySelector('.js-cancel-fork-suggestion'),
document.querySelector('.js-file-fork-suggestion-section'),
);
}
switch (page) {
......@@ -226,9 +233,11 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:pipelines:builds':
case 'projects:pipelines:show':
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
new gl.Pipelines({
initTabs: true,
pipelineStatusUrl,
tabsOptions: {
action: controllerAction,
defaultAction: 'pipelines',
......
......@@ -24,6 +24,7 @@ export default Vue.component('environment-component', {
state: store.state,
visibility: 'available',
isLoading: false,
isLoadingFolderContent: false,
cssContainerClass: environmentsData.cssClass,
endpoint: environmentsData.environmentsDataEndpoint,
canCreateDeployment: environmentsData.canCreateDeployment,
......@@ -68,15 +69,21 @@ export default Vue.component('environment-component', {
this.fetchEnvironments();
eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder);
},
beforeDestroyed() {
eventHub.$off('refreshEnvironments');
eventHub.$off('toggleFolder');
},
methods: {
toggleRow(model) {
return this.store.toggleFolder(model.name);
toggleFolder(folder, folderUrl) {
this.store.toggleFolder(folder);
if (!folder.isOpen) {
this.fetchChildEnvironments(folder, folderUrl);
}
},
/**
......@@ -117,6 +124,21 @@ export default Vue.component('environment-component', {
new Flash('An error occurred while fetching the environments.');
});
},
fetchChildEnvironments(folder, folderUrl) {
this.isLoadingFolderContent = true;
this.service.getFolderContent(folderUrl)
.then(resp => resp.json())
.then((response) => {
this.store.setfolderContent(folder, response.environments);
this.isLoadingFolderContent = false;
})
.catch(() => {
this.isLoadingFolderContent = false;
new Flash('An error occurred while fetching the environments.');
});
},
},
template: `
......@@ -179,7 +201,8 @@ export default Vue.component('environment-component', {
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
:service="service"/>
:service="service"
:is-loading-folder-content="isLoadingFolderContent" />
</div>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
......
......@@ -45,11 +45,20 @@ export default {
new Flash('An error occured while making the request.');
});
},
isActionDisabled(action) {
if (action.playable === undefined) {
return false;
}
return !action.playable;
},
},
template: `
<div class="btn-group" role="group">
<button
type="button"
class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container has-tooltip"
data-container="body"
data-toggle="dropdown"
......@@ -58,15 +67,24 @@ export default {
:disabled="isLoading">
<span>
<span v-html="playIconSvg"></span>
<i class="fa fa-caret-down" aria-hidden="true"></i>
<i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
<i
class="fa fa-caret-down"
aria-hidden="true"/>
<i
v-if="isLoading"
class="fa fa-spinner fa-spin"
aria-hidden="true"/>
</span>
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
<li v-for="action in actions">
<button
type="button"
class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)"
class="js-manual-action-link no-btn">
:class="{ 'disabled': isActionDisabled(action) }"
:disabled="isActionDisabled(action)">
${playIconSvg}
<span>
{{action.name}}
......@@ -74,7 +92,6 @@ export default {
</button>
</li>
</ul>
</button>
</div>
`,
};
......@@ -7,6 +7,7 @@ import RollbackComponent from './environment_rollback';
import TerminalButtonComponent from './environment_terminal_button';
import MonitoringButtonComponent from './environment_monitoring';
import CommitComponent from '../../vue_shared/components/commit';
import eventHub from '../event_hub';
/**
* Envrionment Item Component
......@@ -141,6 +142,7 @@ export default {
const parsedAction = {
name: gl.text.humanize(action.name),
play_path: action.play_path,
playable: action.playable,
};
return parsedAction;
});
......@@ -410,7 +412,6 @@ export default {
folderUrl() {
return `${window.location.pathname}/folders/${this.model.folderName}`;
},
},
/**
......@@ -428,15 +429,37 @@ export default {
return true;
},
methods: {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model, this.folderUrl);
},
},
template: `
<tr>
<tr :class="{ 'js-child-row': model.isChildren }">
<td>
<a v-if="!model.isFolder"
class="environment-name"
:class="{ 'prepend-left-default': model.isChildren }"
:href="environmentPath">
{{model.name}}
</a>
<a v-else class="folder-name" :href="folderUrl">
<span v-else
class="folder-name"
@click="onClickFolder"
role="button">
<span class="folder-icon">
<i
v-show="model.isOpen"
class="fa fa-caret-down"
aria-hidden="true" />
<i
v-show="!model.isOpen"
class="fa fa-caret-right"
aria-hidden="true"/>
</span>
<span class="folder-icon">
<i class="fa fa-folder" aria-hidden="true"></i>
</span>
......@@ -448,7 +471,7 @@ export default {
<span class="badge">
{{model.size}}
</span>
</a>
</span>
</td>
<td class="deployment-column">
......
......@@ -31,6 +31,18 @@ export default {
type: Object,
required: true,
},
isLoadingFolderContent: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
},
},
template: `
......@@ -53,6 +65,31 @@ export default {
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
:service="service"></tr>
<template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
<tr v-if="isLoadingFolderContent">
<td colspan="6" class="text-center">
<i class="fa fa-spin fa-spinner fa-2x" aria-hidden="true"/>
</td>
</tr>
<template v-else>
<tr is="environment-item"
v-for="children in model.children"
:model="children"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
:service="service"></tr>
<tr>
<td colspan="6" class="text-center">
<a :href="folderUrl(model)" class="btn btn-default">
Show all
</a>
</td>
</tr>
</template>
</template>
</template>
</tbody>
</table>
......
......@@ -7,6 +7,7 @@ Vue.use(VueResource);
export default class EnvironmentsService {
constructor(endpoint) {
this.environments = Vue.resource(endpoint);
this.folderResults = 3;
}
get(scope, page) {
......@@ -16,4 +17,8 @@ export default class EnvironmentsService {
postAction(endpoint) {
return Vue.http.post(endpoint, {}, { emulateJSON: true });
}
getFolderContent(folderUrl) {
return Vue.http.get(`${folderUrl}.json?per_page=${this.folderResults}`);
}
}
......@@ -38,7 +38,12 @@ export default class EnvironmentsStore {
let filtered = {};
if (env.size > 1) {
filtered = Object.assign({}, env, { isFolder: true, folderName: env.name });
filtered = Object.assign({}, env, {
isFolder: true,
folderName: env.name,
isOpen: false,
children: [],
});
}
if (env.latest) {
......@@ -85,4 +90,67 @@ export default class EnvironmentsStore {
this.state.stoppedCounter = count;
return count;
}
/**
* Toggles folder open property for the given folder.
*
* @param {Object} folder
* @return {Array}
*/
toggleFolder(folder) {
return this.updateFolder(folder, 'isOpen', !folder.isOpen);
}
/**
* Updates the folder with the received environments.
*
*
* @param {Object} folder Folder to update
* @param {Array} environments Received environments
* @return {Object}
*/
setfolderContent(folder, environments) {
const updatedEnvironments = environments.map((env) => {
let updated = env;
if (env.latest) {
updated = Object.assign({}, env, env.latest);
delete updated.latest;
} else {
updated = env;
}
updated.isChildren = true;
return updated;
});
return this.updateFolder(folder, 'children', updatedEnvironments);
}
/**
* Given a folder a prop and a new value updates the correct folder.
*
* @param {Object} folder
* @param {String} prop
* @param {String|Boolean|Object|Array} newValue
* @return {Array}
*/
updateFolder(folder, prop, newValue) {
const environments = this.state.environments;
const updatedEnvironments = environments.map((env) => {
const updateEnv = Object.assign({}, env);
if (env.isFolder && env.id === folder.id) {
updateEnv[prop] = newValue;
}
return updateEnv;
});
this.state.environments = updatedEnvironments;
return updatedEnvironments;
}
}
import eventHub from '../event_hub';
export default {
name: 'RecentSearchesDropdownContent',
props: {
items: {
type: Array,
required: true,
},
},
computed: {
processedItems() {
return this.items.map((item) => {
const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(item);
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
suffix: `${token.symbol}${token.value}`,
}));
return {
text: item,
tokens: resultantTokens,
searchToken,
};
});
},
hasItems() {
return this.items.length > 0;
},
},
methods: {
onItemActivated(text) {
eventHub.$emit('recentSearchesItemSelected', text);
},
onRequestClearRecentSearches(e) {
// Stop the dropdown from closing
e.stopPropagation();
eventHub.$emit('requestClearRecentSearches');
},
},
template: `
<div>
<ul v-if="hasItems">
<li
v-for="(item, index) in processedItems"
:key="index">
<button
type="button"
class="filtered-search-history-dropdown-item"
@click="onItemActivated(item.text)">
<span>
<span
v-for="(token, tokenIndex) in item.tokens"
class="filtered-search-history-dropdown-token">
<span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span>
</span>
</span>
<span class="filtered-search-history-dropdown-search-token">
{{ item.searchToken }}
</span>
</button>
</li>
<li class="divider"></li>
<li>
<button
type="button"
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)">
Clear recent searches
</button>
</li>
</ul>
<div
v-else
class="dropdown-info-note">
You don't have any recent searches
</div>
</div>
`,
};
......@@ -56,7 +56,7 @@ require('./filtered_search_dropdown');
renderContent() {
const dropdownData = [];
[].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
[].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) {
dropdownData.push(
......
......@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
}
});
return values.join(' ');
return values
.map(value => value.trim())
.join(' ');
}
static getSearchInput(filteredSearchInput) {
......
import Vue from 'vue';
export default new Vue();
/* global Flash */
import FilteredSearchContainer from './container';
import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store';
import RecentSearchesService from './services/recent_searches_service';
import eventHub from './event_hub';
(() => {
class FilteredSearchManager {
constructor(page) {
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.recentSearchesStore = new RecentSearchesStore();
let recentSearchesKey = 'issue-recent-searches';
if (page === 'merge_requests') {
recentSearchesKey = 'merge-request-recent-searches';
}
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
.catch(() => {
// eslint-disable-next-line no-new
new Flash('An error occured while parsing recent searches');
// Gracefully fail to empty array
return [];
})
.then((searches) => {
// Put any searches that may have come in before
// we fetched the saved searches ahead of the already saved ones
const resultantSearches = this.recentSearchesStore.setRecentSearches(
this.recentSearchesStore.state.recentSearches.concat(searches),
);
this.recentSearchesService.save(resultantSearches);
});
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
this.recentSearchesService,
document.querySelector('.js-filtered-search-history-dropdown'),
);
this.recentSearchesRoot.init();
this.bindEvents();
this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown();
......@@ -25,6 +63,10 @@ import FilteredSearchContainer from './container';
cleanup() {
this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper);
if (this.recentSearchesRoot) {
this.recentSearchesRoot.destroy();
}
}
bindEvents() {
......@@ -34,7 +76,7 @@ import FilteredSearchContainer from './container';
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.clearSearchWrapper = this.clearSearch.bind(this);
this.onClearSearchWrapper = this.onClearSearch.bind(this);
this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
......@@ -42,8 +84,8 @@ import FilteredSearchContainer from './container';
this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this);
this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this);
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
......@@ -56,11 +98,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.clearSearchWrapper);
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
unbindEvents() {
......@@ -76,11 +119,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper);
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
checkForBackspace(e) {
......@@ -131,7 +175,7 @@ import FilteredSearchContainer from './container';
}
addInputContainerFocus() {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
if (inputContainer) {
inputContainer.classList.add('focus');
......@@ -139,7 +183,7 @@ import FilteredSearchContainer from './container';
}
removeInputContainerFocus(e) {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
......@@ -161,7 +205,7 @@ import FilteredSearchContainer from './container';
}
unselectEditTokens(e) {
const inputContainer = this.container.querySelector('.filtered-search-input-container');
const inputContainer = this.container.querySelector('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container');
......@@ -215,9 +259,12 @@ import FilteredSearchContainer from './container';
}
}
clearSearch(e) {
onClearSearch(e) {
e.preventDefault();
this.clearSearch();
}
clearSearch() {
this.filteredSearchInput.value = '';
const removeElements = [];
......@@ -289,6 +336,17 @@ import FilteredSearchContainer from './container';
this.search();
}
saveCurrentSearchQuery() {
// Don't save before we have fetched the already saved searches
this.fetchingRecentSearchesPromise.then(() => {
const searchQuery = gl.DropdownUtils.getSearchQuery();
if (searchQuery.length > 0) {
const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery);
this.recentSearchesService.save(resultantSearches);
}
});
}
loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams();
......@@ -343,6 +401,8 @@ import FilteredSearchContainer from './container';
}
});
this.saveCurrentSearchQuery();
if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder();
......@@ -351,8 +411,12 @@ import FilteredSearchContainer from './container';
search() {
const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken }
= this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery());
= this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
......@@ -416,6 +480,13 @@ import FilteredSearchContainer from './container';
currentDropdownRef.dispatchInputEvent();
}
}
onrecentSearchesItemSelected(text) {
this.clearSearch();
this.filteredSearchInput.value = text;
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
}
window.gl = window.gl || {};
......
import Vue from 'vue';
import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content';
import eventHub from './event_hub';
class RecentSearchesRoot {
constructor(
recentSearchesStore,
recentSearchesService,
wrapperElement,
) {
this.store = recentSearchesStore;
this.service = recentSearchesService;
this.wrapperElement = wrapperElement;
}
init() {
this.bindEvents();
this.render();
}
bindEvents() {
this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this);
eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
}
unbindEvents() {
eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
}
render() {
this.vm = new Vue({
el: this.wrapperElement,
data: this.store.state,
template: `
<recent-searches-dropdown-content
:items="recentSearches" />
`,
components: {
'recent-searches-dropdown-content': RecentSearchesDropdownContent,
},
});
}
onRequestClearRecentSearches() {
const resultantSearches = this.store.setRecentSearches([]);
this.service.save(resultantSearches);
}
destroy() {
this.unbindEvents();
if (this.vm) {
this.vm.$destroy();
}
}
}
export default RecentSearchesRoot;
class RecentSearchesService {
constructor(localStorageKey = 'issuable-recent-searches') {
this.localStorageKey = localStorageKey;
}
fetch() {
const input = window.localStorage.getItem(this.localStorageKey);
let searches = [];
if (input && input.length > 0) {
try {
searches = JSON.parse(input);
} catch (err) {
return Promise.reject(err);
}
}
return Promise.resolve(searches);
}
save(searches = []) {
window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
}
}
export default RecentSearchesService;
import _ from 'underscore';
class RecentSearchesStore {
constructor(initialState = {}) {
this.state = Object.assign({
recentSearches: [],
}, initialState);
}
addRecentSearch(newSearch) {
this.setRecentSearches([newSearch].concat(this.state.recentSearches));
return this.state.recentSearches;
}
setRecentSearches(searches = []) {
const trimmedSearches = searches.map(search => search.trim());
this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5);
return this.state.recentSearches;
}
}
export default RecentSearchesStore;
......@@ -45,14 +45,14 @@ window.GroupsSelect = (function() {
page,
per_page: GroupsSelect.PER_PAGE,
all_available,
skip_groups,
};
},
results: function (data, page) {
if (data.length) return { results: [] };
const results = data.length ? data : data.results || [];
const groups = data.length ? data : data.results || [];
const more = data.pagination ? data.pagination.more : false;
const results = groups.filter(group => skip_groups.indexOf(group.id) === -1);
return {
results,
......
import Vue from 'vue';
import IssueTitle from './issue_title';
import '../vue_shared/vue_resource_interceptor';
const vueOptions = () => ({
el: '.issue-title-entrypoint',
components: {
IssueTitle,
},
data() {
const issueTitleData = document.querySelector('.issue-title-data').dataset;
return {
initialTitle: issueTitleData.initialTitle,
endpoint: issueTitleData.endpoint,
};
},
template: `
<IssueTitle
:initialTitle="initialTitle"
:endpoint="endpoint"
/>
`,
});
(() => new Vue(vueOptions()))();
import Visibility from 'visibilityjs';
import Poll from './../lib/utils/poll';
import Service from './services/index';
export default {
props: {
initialTitle: { required: true, type: String },
endpoint: { required: true, type: String },
},
data() {
const resource = new Service(this.$http, this.endpoint);
const poll = new Poll({
resource,
method: 'getTitle',
successCallback: (res) => {
this.renderResponse(res);
},
errorCallback: (err) => {
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.error('ISSUE SHOW TITLE REALTIME ERROR', err);
} else {
throw new Error(err);
}
},
});
return {
poll,
timeoutId: null,
title: this.initialTitle,
};
},
methods: {
fetch() {
this.poll.makeRequest();
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
},
renderResponse(res) {
const body = JSON.parse(res.body);
this.triggerAnimation(body);
},
triggerAnimation(body) {
const { title } = body;
/**
* since opacity is changed, even if there is no diff for Vue to update
* we must check the title even on a 304 to ensure no visual change
*/
if (this.title === title) return;
this.$el.style.opacity = 0;
this.timeoutId = setTimeout(() => {
this.title = title;
this.$el.style.transition = 'opacity 0.2s ease';
this.$el.style.opacity = 1;
clearTimeout(this.timeoutId);
}, 100);
},
},
created() {
this.fetch();
},
template: `
<h2 class='title' v-html='title'></h2>
`,
};
export default class Service {
constructor(resource, endpoint) {
this.resource = resource;
this.endpoint = endpoint;
}
getTitle() {
return this.resource.get(this.endpoint);
}
}
......@@ -2,6 +2,8 @@
(function() {
(function(w) {
var base;
const faviconEl = document.getElementById('favicon');
const originalFavicon = faviconEl ? faviconEl.getAttribute('href') : null;
w.gl || (w.gl = {});
(base = w.gl).utils || (base.utils = {});
w.gl.utils.isInGroupsPage = function() {
......@@ -361,5 +363,34 @@
fn(next, stop);
});
};
w.gl.utils.setFavicon = (iconName) => {
if (faviconEl && iconName) {
faviconEl.setAttribute('href', `/assets/${iconName}.ico`);
}
};
w.gl.utils.resetFavicon = () => {
if (faviconEl) {
faviconEl.setAttribute('href', originalFavicon);
}
};
w.gl.utils.setCiStatusFavicon = (pageUrl) => {
$.ajax({
url: pageUrl,
dataType: 'json',
success: function(data) {
if (data && data.icon) {
gl.utils.setFavicon(`ci_favicons/${data.icon}`);
} else {
gl.utils.resetFavicon();
}
},
error: function() {
gl.utils.resetFavicon();
}
});
};
})(window);
}).call(window);
......@@ -38,11 +38,13 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
function MergeRequestWidget(opts) {
// Initialize MergeRequestWidget behavior
//
// check_enable - Boolean, whether to check automerge status
// merge_check_url - String, URL to use to check automerge status
// check_enable - Boolean, whether to check automerge status
// merge_check_url - String, URL to use to check automerge status
// ci_status_url - String, URL to use to check CI status
// pipeline_status_url - String, URL to use to get CI status for Favicon
//
this.opts = opts;
this.opts.pipeline_status_url = `${this.opts.pipeline_status_url}.json`;
this.$widgetBody = $('.mr-widget-body');
$('#modal_merge_info').modal({
show: false
......@@ -159,6 +161,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
_this.status = data.status;
_this.hasCi = data.has_ci;
_this.updateMergeButton(_this.status, _this.hasCi);
gl.utils.setCiStatusFavicon(_this.opts.pipeline_status_url);
if (data.environments && data.environments.length) _this.renderEnvironments(data.environments);
if (data.status !== _this.opts.ci_status ||
data.sha !== _this.opts.ci_sha ||
......
......@@ -6,7 +6,10 @@ import statusCodes from '~/lib/utils/http_status';
import { formatRelevantDigits } from '~/lib/utils/number_utils';
import '../flash';
const prometheusContainer = '.prometheus-container';
const prometheusParentGraphContainer = '.prometheus-graphs';
const prometheusGraphsContainer = '.prometheus-graph';
const prometheusStatesContainer = '.prometheus-state';
const metricsEndpoint = 'metrics.json';
const timeFormat = d3.time.format('%H:%M');
const dayFormat = d3.time.format('%b %e, %a');
......@@ -14,19 +17,30 @@ const bisectDate = d3.bisector(d => d.time).left;
const extraAddedWidthParent = 100;
class PrometheusGraph {
constructor() {
this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
const parentContainerWidth = $(prometheusGraphsContainer).parent().width() +
extraAddedWidthParent;
this.originalWidth = parentContainerWidth;
this.originalHeight = 330;
this.width = parentContainerWidth - this.margin.left - this.margin.right;
this.height = this.originalHeight - this.margin.top - this.margin.bottom;
this.backOffRequestCounter = 0;
this.configureGraph();
this.init();
const $prometheusContainer = $(prometheusContainer);
const hasMetrics = $prometheusContainer.data('has-metrics');
this.docLink = $prometheusContainer.data('doc-link');
this.integrationLink = $prometheusContainer.data('prometheus-integration');
$(document).ajaxError(() => {});
if (hasMetrics) {
this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
const parentContainerWidth = $(prometheusGraphsContainer).parent().width() +
extraAddedWidthParent;
this.originalWidth = parentContainerWidth;
this.originalHeight = 330;
this.width = parentContainerWidth - this.margin.left - this.margin.right;
this.height = this.originalHeight - this.margin.top - this.margin.bottom;
this.backOffRequestCounter = 0;
this.configureGraph();
this.init();
} else {
this.state = '.js-getting-started';
this.updateState();
}
}
createGraph() {
......@@ -40,8 +54,19 @@ class PrometheusGraph {
init() {
this.getData().then((metricsResponse) => {
if (Object.keys(metricsResponse).length === 0) {
new Flash('Empty metrics', 'alert');
let enoughData = true;
Object.keys(metricsResponse.metrics).forEach((key) => {
let currentKey;
if (key === 'cpu_values' || key === 'memory_values') {
currentKey = metricsResponse.metrics[key];
if (Object.keys(currentKey).length === 0) {
enoughData = false;
}
}
});
if (!enoughData) {
this.state = '.js-loading';
this.updateState();
} else {
this.transformData(metricsResponse);
this.createGraph();
......@@ -345,14 +370,17 @@ class PrometheusGraph {
}
return resp.metrics;
})
.catch(() => new Flash('An error occurred while fetching metrics.', 'alert'));
.catch(() => {
this.state = '.js-unable-to-connect';
this.updateState();
});
}
transformData(metricsResponse) {
Object.keys(metricsResponse.metrics).forEach((key) => {
if (key === 'cpu_values' || key === 'memory_values') {
const metricValues = (metricsResponse.metrics[key])[0];
if (typeof metricValues !== 'undefined') {
if (metricValues !== undefined) {
this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({
time: new Date(metric[0] * 1000),
value: metric[1],
......@@ -361,6 +389,13 @@ class PrometheusGraph {
}
});
}
updateState() {
const $statesContainer = $(prometheusStatesContainer);
$(prometheusParentGraphContainer).hide();
$(`${this.state}`, $statesContainer).removeClass('hidden');
$(prometheusStatesContainer).show();
}
}
export default PrometheusGraph;
......@@ -9,6 +9,10 @@ require('./lib/utils/bootstrap_linked_tabs');
new global.LinkedTabs(options.tabsOptions);
}
if (options.pipelineStatusUrl) {
gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
}
this.addMarginToBuildColumns();
}
......
......@@ -38,6 +38,14 @@ export default {
new Flash('An error occured while making the request.');
});
},
isActionDisabled(action) {
if (action.playable === undefined) {
return false;
}
return !action.playable;
},
},
template: `
......@@ -51,16 +59,23 @@ export default {
aria-label="Manual job"
:disabled="isLoading">
${playIconSvg}
<i class="fa fa-caret-down" aria-hidden="true"></i>
<i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
<i
class="fa fa-caret-down"
aria-hidden="true" />
<i
v-if="isLoading"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
<li v-for="action in actions">
<button
type="button"
class="js-pipeline-action-link no-btn"
@click="onClickAction(action.path)">
class="js-pipeline-action-link no-btn btn"
@click="onClickAction(action.path)"
:class="{ 'disabled': isActionDisabled(action) }"
:disabled="isActionDisabled(action)">
${playIconSvg}
<span>{{action.name}}</span>
</button>
......
/* eslint-disable no-underscore-dangle*/
import '../../vue_realtime_listener';
import VueRealtimeListener from '../../vue_realtime_listener';
export default class PipelinesStore {
constructor() {
......@@ -56,6 +56,6 @@ export default class PipelinesStore {
const removeIntervals = () => clearInterval(this.timeLoopInterval);
const startIntervals = () => startTimeLoops();
gl.VueRealtimeListener(removeIntervals, startIntervals);
VueRealtimeListener(removeIntervals, startIntervals);
}
}
/* eslint-disable no-param-reassign */
((gl) => {
gl.VueRealtimeListener = (removeIntervals, startIntervals) => {
const removeAll = () => {
removeIntervals();
window.removeEventListener('beforeunload', removeIntervals);
window.removeEventListener('focus', startIntervals);
window.removeEventListener('blur', removeIntervals);
document.removeEventListener('beforeunload', removeAll);
};
window.addEventListener('beforeunload', removeIntervals);
window.addEventListener('focus', startIntervals);
window.addEventListener('blur', removeIntervals);
document.addEventListener('beforeunload', removeAll);
// add removeAll methods to stack
const stack = gl.VueRealtimeListener.reset;
gl.VueRealtimeListener.reset = () => {
gl.VueRealtimeListener.reset = stack;
removeAll();
stack();
};
};
// remove all event listeners and intervals
gl.VueRealtimeListener.reset = () => undefined; // noop
})(window.gl || (window.gl = {}));
export default (removeIntervals, startIntervals) => {
window.removeEventListener('focus', startIntervals);
window.removeEventListener('blur', removeIntervals);
window.removeEventListener('onbeforeload', removeIntervals);
window.addEventListener('focus', startIntervals);
window.addEventListener('blur', removeIntervals);
window.addEventListener('onbeforeload', removeIntervals);
};
......@@ -91,7 +91,7 @@
.award-menu-holder {
display: inline-block;
position: relative;
position: absolute;
.tooltip {
white-space: nowrap;
......@@ -117,11 +117,41 @@
&.active,
&:hover,
&:active {
&:active,
&.is-active {
background-color: $row-hover;
border-color: $row-hover-border;
box-shadow: none;
outline: 0;
.award-control-icon svg {
background: $award-emoji-positive-add-bg;
path {
fill: $award-emoji-positive-add-lines;
}
}
.award-control-icon-neutral {
opacity: 0;
}
.award-control-icon-positive {
opacity: 1;
transform: scale(1.15);
}
}
&.is-active {
.award-control-icon-positive {
opacity: 0;
transform: scale(1);
}
.award-control-icon-super-positive {
opacity: 1;
transform: scale(1);
}
}
&.btn {
......@@ -162,9 +192,33 @@
color: $border-gray-normal;
margin-top: 1px;
padding: 0 2px;
svg {
margin-bottom: 1px;
height: 18px;
width: 18px;
border-radius: 50%;
path {
fill: $border-gray-normal;
}
}
}
.award-control-icon-positive,
.award-control-icon-super-positive {
position: absolute;
left: 7px;
bottom: 9px;
opacity: 0;
@include transition(opacity, transform);
}
.award-control-text {
vertical-align: middle;
}
}
.note-awards .award-control-icon-positive {
left: 6px;
}
......@@ -292,6 +292,10 @@
}
@media(min-width: $screen-xs-max) {
&.merge-requests .text-content {
margin-top: 40px;
}
&.labels .text-content {
margin-top: 70px;
}
......
......@@ -177,10 +177,6 @@
border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color;
.filtered-search-input-container & {
max-width: 280px;
}
&.is-loading {
.dropdown-content {
display: none;
......@@ -467,6 +463,11 @@
overflow-y: auto;
}
.dropdown-info-note {
color: $gl-text-color-secondary;
text-align: center;
}
.dropdown-footer {
padding-top: 10px;
margin-top: 10px;
......
......@@ -275,3 +275,22 @@ span.idiff {
}
}
}
.is-stl-loading {
.stl-controls {
display: none;
}
}
.file-fork-suggestion {
display: flex;
align-items: center;
justify-content: flex-end;
background-color: $gray-light;
border-bottom: 1px solid $border-color;
padding: 5px $gl-padding;
}
.file-fork-suggestion-note {
margin-right: 1.5em;
}
......@@ -22,7 +22,6 @@
}
@media (min-width: $screen-sm-min) {
.issues-filters,
.issues_bulk_update {
.dropdown-menu-toggle {
width: 132px;
......@@ -56,7 +55,7 @@
}
}
.filtered-search-container {
.filtered-search-wrapper {
display: -webkit-flex;
display: flex;
......@@ -151,11 +150,13 @@
width: 100%;
}
.filtered-search-input-container {
.filtered-search-box {
position: relative;
flex: 1;
display: -webkit-flex;
display: flex;
position: relative;
width: 100%;
min-width: 0;
border: 1px solid $border-color;
background-color: $white-light;
......@@ -163,14 +164,6 @@
-webkit-flex: 1 1 auto;
flex: 1 1 auto;
margin-bottom: 10px;
.dropdown-menu {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
}
&:hover {
......@@ -229,6 +222,118 @@
}
}
.filtered-search-box-input-container {
flex: 1;
position: relative;
// Fix PhantomJS not supporting `flex: 1;` properly.
// This is important because it can change the expected `e.target` when clicking things in tests.
// See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61
// - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png
// - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png
width: 100%;
min-width: 0;
}
.filtered-search-input-dropdown-menu {
max-width: 280px;
@media (max-width: $screen-xs-min) {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
}
.filtered-search-history-dropdown-toggle-button {
display: flex;
align-items: center;
width: auto;
height: 100%;
padding-top: 0;
padding-left: 0.75em;
padding-bottom: 0;
padding-right: 0.5em;
background-color: transparent;
border-radius: 0;
border-top: 0;
border-left: 0;
border-bottom: 0;
border-right: 1px solid $border-color;
color: $gl-text-color-secondary;
transition: color 0.1s linear;
&:hover,
&:focus {
color: $gl-text-color;
border-color: $dropdown-input-focus-border;
outline: none;
}
.dropdown-toggle-text {
color: inherit;
.fa {
color: inherit;
}
}
.fa {
position: initial;
}
}
.filtered-search-history-dropdown-wrapper {
position: initial;
flex-shrink: 0;
}
.filtered-search-history-dropdown {
width: 40%;
@media (max-width: $screen-xs-min) {
left: 0;
right: 0;
max-width: none;
}
}
.filtered-search-history-dropdown-content {
max-height: none;
}
.filtered-search-history-dropdown-item,
.filtered-search-history-clear-button {
@include dropdown-link;
overflow: hidden;
width: 100%;
margin: 0.5em 0;
background-color: transparent;
border: 0;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
.filtered-search-history-dropdown-token {
display: inline;
&:not(:last-child) {
margin-right: 0.3em;
}
& > .value {
font-weight: 600;
}
}
.filter-dropdown-container {
display: -webkit-flex;
display: flex;
......@@ -248,10 +353,8 @@
}
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
.issues-details-filters {
.dropdown-menu-toggle {
width: 100px;
}
.issue-bulk-update-dropdown-toggle {
width: 100px;
}
}
......
......@@ -16,6 +16,8 @@ body.modal-open {
overflow: hidden;
}
.modal .modal-dialog {
width: 860px;
@media (min-width: $screen-md-min) {
.modal-dialog {
width: 860px;
}
}
......@@ -293,6 +293,8 @@ $badge-color: $gl-text-color-secondary;
* Award emoji
*/
$award-emoji-menu-shadow: rgba(0,0,0,.175);
$award-emoji-positive-add-bg: #fed159;
$award-emoji-positive-add-lines: #bb9c13;
/*
* Search Box
......
/**
* Container Registry
*/
.container-image {
border-bottom: 1px solid $white-normal;
}
.container-image-head {
padding: 0 16px;
line-height: 4em;
}
.table.tags {
margin-bottom: 0;
}
......@@ -233,6 +233,15 @@
stroke-width: 1;
}
.prometheus-state {
margin-top: 10px;
display: none;
.state-button-section {
margin-top: 10px;
}
}
.environments-actions {
.external-url,
.monitoring-url,
......
......@@ -4,14 +4,14 @@
*/
.event-item {
font-size: $gl-font-size;
padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top);
padding: $gl-padding-top 0 $gl-padding-top 40px;
border-bottom: 1px solid $white-normal;
color: $list-text-color;
position: relative;
&.event-inline {
.avatar {
position: relative;
top: -2px;
.profile-icon {
top: 20px;
}
.event-title,
......@@ -24,8 +24,28 @@
color: $gl-text-color;
}
.avatar {
margin-left: -($gl-avatar-size + $gl-padding-top);
.profile-icon {
position: absolute;
left: 0;
top: 14px;
svg {
width: 20px;
height: auto;
fill: $gl-text-color-secondary;
}
&.open-icon svg {
fill: $green-300;
}
&.closed-icon svg {
fill: $red-300;
}
&.fork-icon svg {
fill: $blue-300;
}
}
.event-title {
......@@ -163,7 +183,7 @@
max-width: 100%;
}
.avatar {
.profile-icon {
display: none;
}
......
......@@ -329,8 +329,6 @@
}
#modal_merge_info .modal-dialog {
width: 600px;
.dark {
margin-right: 40px;
}
......
......@@ -398,13 +398,50 @@ ul.notes {
font-size: 17px;
}
&:hover {
svg {
height: 16px;
width: 16px;
fill: $gray-darkest;
vertical-align: text-top;
}
.award-control-icon-positive,
.award-control-icon-super-positive {
position: absolute;
margin-left: -20px;
opacity: 0;
}
&:hover,
&.is-active {
.danger-highlight {
color: $gl-text-red;
}
.link-highlight {
color: $gl-link-color;
svg {
fill: $gl-link-color;
}
}
.award-control-icon-neutral {
opacity: 0;
}
.award-control-icon-positive {
opacity: 1;
}
}
&.is-active {
.award-control-icon-positive {
opacity: 0;
}
.award-control-icon-super-positive {
opacity: 1;
}
}
}
......@@ -508,7 +545,6 @@ ul.notes {
}
.line-resolve-all-container {
.btn-group {
margin-left: -4px;
}
......@@ -537,7 +573,6 @@ ul.notes {
fill: $gray-darkest;
}
}
}
.line-resolve-all {
......
......@@ -230,6 +230,14 @@
font-size: 0;
}
.fade-right {
right: 0;
}
.fade-left {
left: 0;
}
@media (max-width: $screen-xs-max) {
.cover-block {
padding-top: 20px;
......
......@@ -459,20 +459,13 @@ a.deploy-project-label {
flex-wrap: wrap;
.btn {
margin: 0 10px 10px 0;
padding: 8px;
margin-left: 10px;
}
> div {
margin-bottom: 10px;
padding-left: 0;
&:last-child {
margin-bottom: 0;
.btn {
margin-right: 0;
}
}
}
}
}
......
......@@ -6,6 +6,8 @@
}
.trigger-actions {
white-space: nowrap;
.btn {
margin-left: 10px;
}
......
......@@ -145,8 +145,6 @@
margin: 0;
}
#modal-remove-blob > .modal-dialog { width: 850px; }
.blob-upload-dropzone-previews {
text-align: center;
border: 2px;
......
......@@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController
:name,
:path,
:request_access_enabled,
:visibility_level
:visibility_level,
:require_two_factor_authentication,
:two_factor_grace_period
]
end
end
......@@ -3,6 +3,7 @@ class Admin::ProjectsController < Admin::ApplicationController
before_action :group, only: [:show, :transfer]
def index
params[:sort] ||= 'latest_activity_desc'
@projects = Project.with_statistics
@projects = @projects.in_namespace(params[:namespace_id]) if params[:namespace_id].present?
@projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present?
......
......@@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base
include PageLayoutHelper
include SentryHelper
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
before_action :authenticate_user_from_private_token!
before_action :authenticate_user!
before_action :validate_user_service_ticket!
before_action :check_password_expiration
before_action :check_2fa_requirement
before_action :ldap_security_check
before_action :sentry_context
before_action :default_headers
......@@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base
end
end
def check_2fa_requirement
if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
redirect_to profile_two_factor_auth_path
end
end
def ldap_security_check
if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease
......@@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base
current_application_settings.import_sources.include?('gitlab_project')
end
def two_factor_authentication_required?
current_application_settings.require_two_factor_authentication
end
def two_factor_grace_period
current_application_settings.two_factor_grace_period
end
def two_factor_grace_period_expired?
date = current_user.otp_grace_period_started_at
date && (date + two_factor_grace_period.hours) < Time.current
end
def skip_two_factor?
session[:skip_tfa] && session[:skip_tfa] > Time.current
end
# U2F (universal 2nd factor) devices need a unique identifier for the application
# to perform authentication.
# https://developers.yubico.com/U2F/App_ID.html
......
......@@ -7,6 +7,7 @@ module ContinueParams
continue_params = continue_params.permit(:to, :notice, :notice_now)
return unless continue_params[:to] && continue_params[:to].start_with?('/')
return if continue_params[:to].start_with?('//')
continue_params
end
......
# == EnforcesTwoFactorAuthentication
#
# Controller concern to enforce two-factor authentication requirements
#
# Upon inclusion, adds `check_two_factor_requirement` as a before_action,
# and makes `two_factor_grace_period_expired?` and `two_factor_skippable?`
# available as view helpers.
module EnforcesTwoFactorAuthentication
extend ActiveSupport::Concern
included do
before_action :check_two_factor_requirement
helper_method :two_factor_grace_period_expired?, :two_factor_skippable?
end
def check_two_factor_requirement
if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
redirect_to profile_two_factor_auth_path
end
end
def two_factor_authentication_required?
current_application_settings.require_two_factor_authentication? ||
current_user.try(:require_two_factor_authentication_from_group?)
end
def two_factor_authentication_reason(global: -> {}, group: -> {})
if two_factor_authentication_required?
if current_application_settings.require_two_factor_authentication?
global.call
else
groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc)
group.call(groups)
end
end
end
def two_factor_grace_period
periods = [current_application_settings.two_factor_grace_period]
periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?)
periods.min
end
def two_factor_grace_period_expired?
date = current_user.otp_grace_period_started_at
date && (date + two_factor_grace_period.hours) < Time.current
end
def two_factor_skippable?
two_factor_authentication_required? &&
!current_user.two_factor_enabled? &&
!two_factor_grace_period_expired?
end
def skip_two_factor?
session[:skip_two_factor] && session[:skip_two_factor] > Time.current
end
end
# == FilterProjects
#
# Controller concern to handle projects filtering
# * by name
# * by archived state
#
module FilterProjects
extend ActiveSupport::Concern
def filter_projects(projects)
projects = projects.search(params[:name]) if params[:name].present?
projects = projects.non_archived if params[:archived].blank?
projects = projects.personal(current_user) if params[:personal].present? && current_user
projects
end
end
module ParamsBackwardCompatibility
private
def set_non_archived_param
params[:non_archived] = params[:archived].blank?
end
end
class Dashboard::ProjectsController < Dashboard::ApplicationController
include FilterProjects
include ParamsBackwardCompatibility
before_action :set_non_archived_param
before_action :default_sorting
def index
@projects = load_projects(current_user.authorized_projects)
@projects = @projects.sort(@sort = params[:sort])
@projects = @projects.page(params[:page])
@projects = load_projects(params.merge(non_public: true)).page(params[:page])
respond_to do |format|
format.html { @last_push = current_user.recent_push }
......@@ -21,10 +22,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def starred
@projects = load_projects(current_user.viewable_starred_projects)
@projects = @projects.includes(:forked_from_project, :tags)
@projects = @projects.sort(@sort = params[:sort])
@projects = @projects.page(params[:page])
@projects = load_projects(params.merge(starred: true)).
includes(:forked_from_project, :tags).page(params[:page])
@last_push = current_user.recent_push
@groups = []
......@@ -41,14 +40,18 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
private
def load_projects(base_scope)
projects = base_scope.sorted_by_activity.includes(:route, namespace: :route)
def default_sorting
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
end
filter_projects(projects)
def load_projects(finder_params)
ProjectsFinder.new(params: finder_params, current_user: current_user).
execute.includes(:route, namespace: :route)
end
def load_events
@events = Event.in_projects(load_projects(current_user.authorized_projects))
@events = Event.in_projects(load_projects(params.merge(non_public: true)))
@events = event_filter.apply_filter(@events).with_associations
@events = @events.limit(20).offset(params[:offset] || 0)
end
......
......@@ -7,7 +7,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
@sort = params[:sort]
@todos = @todos.page(params[:page])
if @todos.out_of_range? && @todos.total_pages != 0
redirect_to url_for(params.merge(page: @todos.total_pages))
redirect_to url_for(params.merge(page: @todos.total_pages, only_path: true))
end
end
......
class Explore::ProjectsController < Explore::ApplicationController
include FilterProjects
include ParamsBackwardCompatibility
before_action :set_non_archived_param
def index
@projects = load_projects
@tags = @projects.tags_on(:tags)
@projects = @projects.tagged_with(params[:tag]) if params[:tag].present?
@projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present?
@projects = filter_projects(@projects)
@projects = @projects.sort(@sort = params[:sort])
@projects = @projects.includes(:namespace).page(params[:page])
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
@projects = load_projects.page(params[:page])
respond_to do |format|
format.html
......@@ -21,10 +19,9 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def trending
@projects = load_projects(Project.trending)
@projects = filter_projects(@projects)
@projects = @projects.sort(@sort = params[:sort])
@projects = @projects.page(params[:page])
params[:trending] = true
@sort = params[:sort]
@projects = load_projects.page(params[:page])
respond_to do |format|
format.html
......@@ -37,10 +34,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def starred
@projects = load_projects
@projects = filter_projects(@projects)
@projects = @projects.reorder('star_count DESC')
@projects = @projects.page(params[:page])
@projects = load_projects.reorder('star_count DESC').page(params[:page])
respond_to do |format|
format.html
......@@ -52,10 +46,10 @@ class Explore::ProjectsController < Explore::ApplicationController
end
end
protected
private
def load_projects(base_scope = nil)
base_scope ||= ProjectsFinder.new.execute(current_user)
base_scope.includes(:route, namespace: :route)
def load_projects
ProjectsFinder.new(current_user: current_user, params: params).
execute.includes(:route, namespace: :route)
end
end
......@@ -10,6 +10,7 @@ class Groups::ApplicationController < ApplicationController
unless @group
id = params[:group_id] || params[:id]
@group = Group.find_by_full_path(id)
@group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute
unless @group && can?(current_user, :read_group, @group)
@group = nil
......@@ -26,7 +27,7 @@ class Groups::ApplicationController < ApplicationController
end
def group_projects
@projects ||= GroupProjectsFinder.new(group).execute(current_user)
@projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute
end
def authorize_admin_group!
......
class GroupsController < Groups::ApplicationController
include FilterProjects
include IssuesAction
include MergeRequestsAction
include ParamsBackwardCompatibility
respond_to :html
......@@ -105,15 +105,16 @@ class GroupsController < Groups::ApplicationController
protected
def setup_projects
set_non_archived_param
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
options = {}
options[:only_owned] = true if params[:shared] == '0'
options[:only_shared] = true if params[:shared] == '1'
@projects = GroupProjectsFinder.new(group, options).execute(current_user)
@projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute
@projects = @projects.includes(:namespace)
@projects = @projects.sorted_by_activity
@projects = filter_projects(@projects)
@projects = @projects.sort(@sort = params[:sort])
@projects = @projects.page(params[:page]) if params[:name].blank?
end
......@@ -150,7 +151,9 @@ class GroupsController < Groups::ApplicationController
:visibility_level,
:parent_id,
:create_chat_team,
:chat_team_name
:chat_team_name,
:require_two_factor_authentication,
:two_factor_grace_period
]
end
......
class Import::BaseController < ApplicationController
private
def find_or_create_namespace(name, owner)
return current_user.namespace if name == owner
def find_or_create_namespace(names, owner)
return current_user.namespace if names == owner
return current_user.namespace unless current_user.can_create_group?
begin
name = params[:target_namespace].presence || name
namespace = Group.create!(name: name, path: name, owner: current_user)
namespace.add_owner(current_user)
namespace
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
Namespace.find_by_full_path(name)
names = params[:target_namespace].presence || names
full_path_namespace = Namespace.find_by_full_path(names)
return full_path_namespace if full_path_namespace
names.split('/').inject(nil) do |parent, name|
begin
namespace = Group.create!(name: name,
path: name,
owner: current_user,
parent: parent)
namespace.add_owner(current_user)
namespace
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
Namespace.where(parent: parent).find_by_path_or_name(name)
end
end
end
end
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_2fa_requirement
skip_before_action :check_two_factor_requirement
def show
unless current_user.otp_secret
......@@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.save! if current_user.changed?
if two_factor_authentication_required? && !current_user.two_factor_enabled?
if two_factor_grace_period_expired?
flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.'
else
two_factor_authentication_reason(
global: lambda do
flash.now[:alert] =
'The global settings require you to enable Two-Factor Authentication for your account.'
end,
group: lambda do |groups|
group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
flash.now[:alert] = %{
The group settings for #{group_links} require you to enable
Two-Factor Authentication for your account.
}.html_safe
end
)
unless two_factor_grace_period_expired?
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}."
flash.now[:alert] << " You need to do this before #{l(grace_period_deadline)}."
end
end
......@@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
if two_factor_grace_period_expired?
redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
else
session[:skip_tfa] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
redirect_to root_path
end
end
......
......@@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController
# Raised when given an invalid file path
InvalidPathError = Class.new(StandardError)
prepend_before_action :authenticate_user!, only: [:edit]
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
before_action :authorize_edit_tree!, only: [:new, :create, :edit, :update, :destroy]
before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
before_action :assign_blob_vars
before_action :commit, except: [:new, :create]
before_action :blob, except: [:new, :create]
......@@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController
end
def edit
blob.load_all_data!(@repository)
if can_collaborate_with_project?
blob.load_all_data!(@repository)
else
redirect_to action: 'show'
end
end
def update
......
......@@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
respond_to do |format|
format.html
format.json do
render json: {
id: @build.id,
status: @build.status,
trace_html: @build.trace_html
}
end
end
end
def trace
respond_to do |format|
format.json do
state = params[:state].presence
render json: @build.trace_with_state(state: state).
merge!(id: @build.id, status: @build.status)
build.trace.read do |stream|
respond_to do |format|
format.json do
result = {
id: @build.id, status: @build.status, complete: @build.complete?
}
if stream.valid?
stream.limit
state = params[:state].presence
trace = stream.html_with_state(state)
result.merge!(trace.to_h)
end
render json: result
end
end
end
end
......@@ -86,10 +86,12 @@ class Projects::BuildsController < Projects::ApplicationController
end
def raw
if @build.has_trace_file?
send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
build.trace.read do |stream|
if stream.file?
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
end
end
end
......
class Projects::ContainerRegistryController < Projects::ApplicationController
before_action :verify_registry_enabled
before_action :authorize_read_container_image!
before_action :authorize_update_container_image!, only: [:destroy]
layout 'project'
def index
@tags = container_registry_repository.tags
end
def destroy
url = namespace_project_container_registry_index_path(project.namespace, project)
if tag.delete
redirect_to url
else
redirect_to url, alert: 'Failed to remove tag'
end
end
private
def verify_registry_enabled
render_404 unless Gitlab.config.registry.enabled
end
def container_registry_repository
@container_registry_repository ||= project.container_registry_repository
end
def tag
@tag ||= container_registry_repository.tag(params[:id])
end
end
......@@ -9,7 +9,7 @@ class Projects::ForksController < Projects::ApplicationController
def index
base_query = project.forks.includes(:creator)
@forks = base_query.merge(ProjectsFinder.new.execute(current_user))
@forks = base_query.merge(ProjectsFinder.new(current_user: current_user).execute)
@total_forks_count = base_query.size
@private_forks_count = @total_forks_count - @forks.size
@public_forks_count = @total_forks_count - @private_forks_count
......
......@@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled
before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests,
:related_branches, :can_create_branch]
:related_branches, :can_create_branch, :rendered_title]
# Allow read any issue
before_action :authorize_read_issue!, only: [:show]
before_action :authorize_read_issue!, only: [:show, :rendered_title]
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
......@@ -31,7 +31,7 @@ class Projects::IssuesController < Projects::ApplicationController
@issuable_meta_data = issuable_meta_data(@issues, @collection_type)
if @issues.out_of_range? && @issues.total_pages != 0
return redirect_to url_for(params.merge(page: @issues.total_pages))
return redirect_to url_for(params.merge(page: @issues.total_pages, only_path: true))
end
if params[:label_name].present?
......@@ -200,6 +200,11 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
def rendered_title
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: { title: view_context.markdown_field(@issue, :title) }
end
protected
def issue
......
......@@ -43,7 +43,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type)
if @merge_requests.out_of_range? && @merge_requests.total_pages != 0
return redirect_to url_for(params.merge(page: @merge_requests.total_pages))
return redirect_to url_for(params.merge(page: @merge_requests.total_pages, only_path: true))
end
if params[:label_name].present?
......@@ -452,7 +452,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
if pipeline
status = pipeline.status
coverage = pipeline.try(:coverage)
coverage = pipeline.coverage
status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
......
module Projects
module Registry
class ApplicationController < Projects::ApplicationController
layout 'project'
before_action :verify_registry_enabled!
before_action :authorize_read_container_image!
private
def verify_registry_enabled!
render_404 unless Gitlab.config.registry.enabled
end
end
end
end
module Projects
module Registry
class RepositoriesController < ::Projects::Registry::ApplicationController
before_action :authorize_update_container_image!, only: [:destroy]
before_action :ensure_root_container_repository!, only: [:index]
def index
@images = project.container_repositories
end
def destroy
if image.destroy
redirect_to project_container_registry_path(@project),
notice: 'Image repository has been removed successfully!'
else
redirect_to project_container_registry_path(@project),
alert: 'Failed to remove image repository!'
end
end
private
def image
@image ||= project.container_repositories.find(params[:id])
end
##
# Container repository object for root project path.
#
# Needed to maintain a backwards compatibility.
#
def ensure_root_container_repository!
ContainerRegistry::Path.new(@project.full_path).tap do |path|
break if path.has_repository?
ContainerRepository.build_from_path(path).tap do |repository|
repository.save! if repository.has_tags?
end
end
end
end
end
end
module Projects
module Registry
class TagsController < ::Projects::Registry::ApplicationController
before_action :authorize_update_container_image!, only: [:destroy]
def destroy
if tag.delete
redirect_to project_container_registry_path(@project),
notice: 'Registry tag has been removed successfully!'
else
redirect_to project_container_registry_path(@project),
alert: 'Failed to remove registry tag!'
end
end
private
def image
@image ||= project.container_repositories
.find(params[:repository_id])
end
def tag
@tag ||= image.tag(params[:id])
end
end
end
end
......@@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController
include Devise::Controllers::Rememberable
include Recaptcha::ClientHelper
skip_before_action :check_2fa_requirement, only: [:destroy]
skip_before_action :check_two_factor_requirement, only: [:destroy]
prepend_before_action :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor,
......
......@@ -140,6 +140,6 @@ class UsersController < ApplicationController
end
def projects_for_current_user
ProjectsFinder.new.execute(current_user)
ProjectsFinder.new(current_user: current_user).execute
end
end
class GroupProjectsFinder < UnionFinder
def initialize(group, options = {})
# GroupProjectsFinder
#
# Used to filter Projects by set of params
#
# Arguments:
# current_user - which user use
# project_ids_relation: int[] - project ids to use
# group
# options:
# only_owned: boolean
# only_shared: boolean
# params:
# sort: string
# visibility_level: int
# tags: string[]
# personal: boolean
# search: string
# non_archived: boolean
#
class GroupProjectsFinder < ProjectsFinder
attr_reader :group, :options
def initialize(group:, params: {}, options: {}, current_user: nil, project_ids_relation: nil)
super(params: params, current_user: current_user, project_ids_relation: project_ids_relation)
@group = group
@options = options
end
def execute(current_user = nil)
segments = group_projects(current_user)
find_union(segments, Project)
end
private
def group_projects(current_user)
only_owned = @options.fetch(:only_owned, false)
only_shared = @options.fetch(:only_shared, false)
def init_collection
only_owned = options.fetch(:only_owned, false)
only_shared = options.fetch(:only_shared, false)
projects = []
if current_user
if @group.users.include?(current_user)
projects << @group.projects unless only_shared
projects << @group.shared_projects unless only_owned
if group.users.include?(current_user)
projects << group.projects unless only_shared
projects << group.shared_projects unless only_owned
else
unless only_shared
projects << @group.projects.visible_to_user(current_user)
projects << @group.projects.public_to_user(current_user)
projects << group.projects.visible_to_user(current_user)
projects << group.projects.public_to_user(current_user)
end
unless only_owned
projects << @group.shared_projects.visible_to_user(current_user)
projects << @group.shared_projects.public_to_user(current_user)
projects << group.shared_projects.visible_to_user(current_user)
projects << group.shared_projects.public_to_user(current_user)
end
end
else
projects << @group.projects.public_only unless only_shared
projects << @group.shared_projects.public_only unless only_owned
projects << group.projects.public_only unless only_shared
projects << group.shared_projects.public_only unless only_owned
end
projects
end
def union(items)
find_union(items, Project)
end
end
......@@ -116,9 +116,9 @@ class IssuableFinder
if current_user && params[:authorized_only].presence && !current_user_related?
current_user.authorized_projects
elsif group
GroupProjectsFinder.new(group).execute(current_user)
GroupProjectsFinder.new(group: group, current_user: current_user).execute
else
projects_finder.execute(current_user, item_project_ids(items))
ProjectsFinder.new(current_user: current_user, project_ids_relation: item_project_ids(items)).execute
end
@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
......@@ -405,8 +405,4 @@ class IssuableFinder
def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end
def projects_finder
@projects_finder ||= ProjectsFinder.new
end
end
......@@ -83,7 +83,7 @@ class LabelsFinder < UnionFinder
def projects
return @projects if defined?(@projects)
@projects = skip_authorization ? Project.all : ProjectsFinder.new.execute(current_user)
@projects = skip_authorization ? Project.all : ProjectsFinder.new(current_user: current_user).execute
@projects = @projects.in_namespace(params[:group_id]) if group?
@projects = @projects.where(id: params[:project_ids]) if projects?
@projects = @projects.reorder(nil)
......
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.
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.
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.
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