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 ...@@ -30,6 +30,7 @@ eslint-report.html
/config/unicorn.rb /config/unicorn.rb
/config/secrets.yml /config/secrets.yml
/config/sidekiq.yml /config/sidekiq.yml
/config/registry.key
/coverage/* /coverage/*
/coverage-javascript/ /coverage-javascript/
/db/*.sqlite3 /db/*.sqlite3
......
...@@ -2,6 +2,31 @@ ...@@ -2,6 +2,31 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. 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) ## 9.0.2 (2017-03-29)
- Correctly update paths when changing a child group. - Correctly update paths when changing a child group.
...@@ -303,6 +328,14 @@ entry. ...@@ -303,6 +328,14 @@ entry.
- Change development tanuki favicon colors to match logo color order. - Change development tanuki favicon colors to match logo color order.
- API issues - support filtering by iids. - 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) ## 8.17.4 (2017-03-19)
- Only show public emails in atom feeds. - Only show public emails in atom feeds.
...@@ -516,6 +549,14 @@ entry. ...@@ -516,6 +549,14 @@ entry.
- Remove deprecated GitlabCiService. - Remove deprecated GitlabCiService.
- Requeue pending deletion projects. - 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) ## 8.16.8 (2017-03-19)
- Only show public emails in atom feeds. - Only show public emails in atom feeds.
......
...@@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4' ...@@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4'
gem 'redis-namespace', '~> 1.5.2' gem 'redis-namespace', '~> 1.5.2'
gem 'sidekiq-limit_fetch', '~> 3.4' gem 'sidekiq-limit_fetch', '~> 3.4'
# Cron Parser
gem 'rufus-scheduler', '~> 3.1.10'
# HTTP requests # HTTP requests
gem 'httparty', '~> 0.13.3' gem 'httparty', '~> 0.13.3'
......
...@@ -987,6 +987,7 @@ DEPENDENCIES ...@@ -987,6 +987,7 @@ DEPENDENCIES
rubocop-rspec (~> 1.12.0) rubocop-rspec (~> 1.12.0)
ruby-fogbugz (~> 0.2.1) ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.16.2) ruby-prof (~> 0.16.2)
rufus-scheduler (~> 3.1.10)
rugged (~> 0.25.1.1) rugged (~> 0.25.1.1)
sanitize (~> 2.0) sanitize (~> 2.0)
sass-rails (~> 5.0.6) sass-rails (~> 5.0.6)
......
...@@ -33,7 +33,7 @@ core team members will mention this person. ...@@ -33,7 +33,7 @@ core team members will mention this person.
### Merge request coaching ### Merge request coaching
Several people from the [GitLab team][team] are helping community members to get 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/. 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, ...@@ -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. 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. 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) 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. 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. 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 ...@@ -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 [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 ["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 [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( ...@@ -263,7 +263,8 @@ AwardsHandler.prototype.addAward = function addAward(
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined; 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( AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar(
...@@ -476,10 +477,10 @@ AwardsHandler.prototype.setupSearch = function setupSearch() { ...@@ -476,10 +477,10 @@ AwardsHandler.prototype.setupSearch = function setupSearch() {
this.registerEventListener('on', $('input.emoji-search'), 'input', (e) => { this.registerEventListener('on', $('input.emoji-search'), 'input', (e) => {
const term = $(e.target).val().trim(); const term = $(e.target).val().trim();
// Clean previous search results // 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) { if (term.length > 0) {
// Generate a search result block // 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 foundEmojis = this.searchEmojis(term).show();
const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis); const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
$('.emoji-menu-content ul, .emoji-menu-content h5').hide(); $('.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 */ /* eslint-disable comma-dangle, space-before-function-paren, one-var */
/* global Sortable */ /* global Sortable */
import Vue from 'vue'; import Vue from 'vue';
import boardList from './board_list';
import boardBlankState from './board_blank_state'; import boardBlankState from './board_blank_state';
require('./board_delete'); require('./board_delete');
...@@ -16,7 +16,7 @@ require('./board_list'); ...@@ -16,7 +16,7 @@ require('./board_list');
gl.issueBoards.Board = Vue.extend({ gl.issueBoards.Board = Vue.extend({
template: '#js-board-template', template: '#js-board-template',
components: { components: {
'board-list': gl.issueBoards.BoardList, boardList,
'board-delete': gl.issueBoards.BoardDelete, 'board-delete': gl.issueBoards.BoardDelete,
boardBlankState, boardBlankState,
}, },
......
/* eslint-disable comma-dangle, space-before-function-paren, max-len */
/* global Sortable */ /* global Sortable */
import Vue from 'vue';
import boardNewIssue from './board_new_issue'; import boardNewIssue from './board_new_issue';
import boardCard from './board_card'; import boardCard from './board_card';
import eventHub from '../eventhub';
(() => { const Store = gl.issueBoards.BoardsStore;
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardList = Vue.extend({ export default {
template: '#js-board-list-template', name: 'BoardList',
components: { props: {
boardCard, disabled: {
boardNewIssue, type: Boolean,
required: true,
}, },
props: { list: {
disabled: Boolean, type: Object,
list: Object, required: true,
issues: Array,
loading: Boolean,
issueLinkBase: String,
rootPath: String,
}, },
data () { issues: {
return { type: Array,
scrollOffset: 250, required: true,
filters: Store.state.filters,
showCount: false,
showIssueForm: false
};
}, },
watch: { loading: {
filters: { type: Boolean,
handler () { required: true,
this.list.loadingMore = false; },
this.$refs.list.scrollTop = 0; issueLinkBase: {
}, type: String,
deep: true required: true,
}, },
issues () { rootPath: {
this.$nextTick(() => { type: String,
if (this.scrollHeight() <= this.listHeight() && this.list.issuesSize > this.list.issues.length) { required: true,
this.list.page += 1; },
this.list.getIssues(false); },
} 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())) { if (getIssues) {
this.showCount = true; this.list.loadingMore = true;
} else { getIssues.then(() => {
this.showCount = false; this.list.loadingMore = false;
}
}); });
} }
}, },
methods: { toggleForm() {
listHeight () { this.showIssueForm = !this.showIssueForm;
return this.$refs.list.getBoundingClientRect().height; },
}, onScroll() {
scrollHeight () { if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
return this.$refs.list.scrollHeight; this.loadNextPage();
}, }
scrollTop () { },
return this.$refs.list.scrollTop + this.listHeight(); },
watch: {
filters: {
handler() {
this.list.loadingMore = false;
this.$refs.list.scrollTop = 0;
}, },
loadNextPage () { deep: true,
const getIssues = this.list.nextPage(); },
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) { if (this.scrollHeight() > Math.ceil(this.listHeight())) {
this.list.loadingMore = true; this.showCount = true;
getIssues.then(() => { } else {
this.list.loadingMore = false; 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({ created() {
scroll: document.querySelectorAll('.boards-list')[0], eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm);
group: 'issues', },
disabled: this.disabled, mounted() {
filter: '.board-list-count, .is-disabled', const options = gl.issueBoards.getBoardSortableDefaultOptions({
dataIdAttr: 'data-issue-id', scroll: document.querySelectorAll('.boards-list')[0],
onStart: (e) => { group: 'issues',
const card = this.$refs.issue[e.oldIndex]; 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; card.showDetail = false;
Store.moving.list = card.list; Store.moving.list = card.list;
Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId); Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId);
gl.issueBoards.onStart(); gl.issueBoards.onStart();
}, },
onAdd: (e) => { onAdd: (e) => {
gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex); gl.issueBoards.BoardsStore
.moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex);
this.$nextTick(() => { this.$nextTick(() => {
e.item.remove(); e.item.remove();
}); });
}, },
onUpdate: (e) => { onUpdate: (e) => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1'); const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
gl.issueBoards.BoardsStore.moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray); gl.issueBoards.BoardsStore
}, .moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
onMove(e) { },
return !e.related.classList.contains('board-list-count'); 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 // Scroll event on list to load more
this.$refs.list.onscroll = () => { this.$refs.list.addEventListener('scroll', this.onScroll);
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { },
this.loadNextPage(); beforeDestroy() {
} eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm);
}; this.$refs.list.removeEventListener('scroll', this.onScroll);
}, },
beforeDestroy() { template: `
gl.IssueBoardsApp.$off(`hide-issue-form-${this.list.id}`, this.toggleForm); <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 */ /* global ListIssue */
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
export default { export default {
...@@ -49,7 +51,7 @@ export default { ...@@ -49,7 +51,7 @@ export default {
}, },
cancel() { cancel() {
this.title = ''; this.title = '';
gl.IssueBoardsApp.$emit(`hide-issue-form-${this.list.id}`); eventHub.$emit(`hide-issue-form-${this.list.id}`);
}, },
}, },
mounted() { mounted() {
......
...@@ -84,10 +84,11 @@ window.Build = (function() { ...@@ -84,10 +84,11 @@ window.Build = (function() {
var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']; var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'];
return $.ajax({ return $.ajax({
url: this.buildUrl, url: this.pageUrl + "/trace.json",
dataType: 'json', dataType: 'json',
success: function(buildData) { 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) { if (window.location.hash === DOWN_BUILD_TRACE) {
$("html,body").scrollTop(this.$buildTrace.height()); $("html,body").scrollTop(this.$buildTrace.height());
} }
......
...@@ -43,6 +43,7 @@ import GroupsList from './groups_list'; ...@@ -43,6 +43,7 @@ import GroupsList from './groups_list';
import ProjectsList from './projects_list'; import ProjectsList from './projects_list';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout'; import UserCallout from './user_callout';
const ShortcutsBlob = require('./shortcuts_blob'); const ShortcutsBlob = require('./shortcuts_blob');
...@@ -86,6 +87,12 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -86,6 +87,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
skipResetBindings: true, skipResetBindings: true,
fileBlobPermalinkUrl, 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) { switch (page) {
...@@ -226,9 +233,11 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -226,9 +233,11 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:pipelines:builds': case 'projects:pipelines:builds':
case 'projects:pipelines:show': case 'projects:pipelines:show':
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
new gl.Pipelines({ new gl.Pipelines({
initTabs: true, initTabs: true,
pipelineStatusUrl,
tabsOptions: { tabsOptions: {
action: controllerAction, action: controllerAction,
defaultAction: 'pipelines', defaultAction: 'pipelines',
......
...@@ -24,6 +24,7 @@ export default Vue.component('environment-component', { ...@@ -24,6 +24,7 @@ export default Vue.component('environment-component', {
state: store.state, state: store.state,
visibility: 'available', visibility: 'available',
isLoading: false, isLoading: false,
isLoadingFolderContent: false,
cssContainerClass: environmentsData.cssClass, cssContainerClass: environmentsData.cssClass,
endpoint: environmentsData.environmentsDataEndpoint, endpoint: environmentsData.environmentsDataEndpoint,
canCreateDeployment: environmentsData.canCreateDeployment, canCreateDeployment: environmentsData.canCreateDeployment,
...@@ -68,15 +69,21 @@ export default Vue.component('environment-component', { ...@@ -68,15 +69,21 @@ export default Vue.component('environment-component', {
this.fetchEnvironments(); this.fetchEnvironments();
eventHub.$on('refreshEnvironments', this.fetchEnvironments); eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder);
}, },
beforeDestroyed() { beforeDestroyed() {
eventHub.$off('refreshEnvironments'); eventHub.$off('refreshEnvironments');
eventHub.$off('toggleFolder');
}, },
methods: { methods: {
toggleRow(model) { toggleFolder(folder, folderUrl) {
return this.store.toggleFolder(model.name); this.store.toggleFolder(folder);
if (!folder.isOpen) {
this.fetchChildEnvironments(folder, folderUrl);
}
}, },
/** /**
...@@ -117,6 +124,21 @@ export default Vue.component('environment-component', { ...@@ -117,6 +124,21 @@ export default Vue.component('environment-component', {
new Flash('An error occurred while fetching the environments.'); 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: ` template: `
...@@ -179,7 +201,8 @@ export default Vue.component('environment-component', { ...@@ -179,7 +201,8 @@ export default Vue.component('environment-component', {
:environments="state.environments" :environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed" :can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed" :can-read-environment="canReadEnvironmentParsed"
:service="service"/> :service="service"
:is-loading-folder-content="isLoadingFolderContent" />
</div> </div>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" <table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
......
...@@ -45,11 +45,20 @@ export default { ...@@ -45,11 +45,20 @@ export default {
new Flash('An error occured while making the request.'); new Flash('An error occured while making the request.');
}); });
}, },
isActionDisabled(action) {
if (action.playable === undefined) {
return false;
}
return !action.playable;
},
}, },
template: ` template: `
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button <button
type="button"
class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container has-tooltip" class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container has-tooltip"
data-container="body" data-container="body"
data-toggle="dropdown" data-toggle="dropdown"
...@@ -58,15 +67,24 @@ export default { ...@@ -58,15 +67,24 @@ export default {
:disabled="isLoading"> :disabled="isLoading">
<span> <span>
<span v-html="playIconSvg"></span> <span v-html="playIconSvg"></span>
<i class="fa fa-caret-down" aria-hidden="true"></i> <i
<i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> class="fa fa-caret-down"
aria-hidden="true"/>
<i
v-if="isLoading"
class="fa fa-spinner fa-spin"
aria-hidden="true"/>
</span> </span>
</button>
<ul class="dropdown-menu dropdown-menu-align-right"> <ul class="dropdown-menu dropdown-menu-align-right">
<li v-for="action in actions"> <li v-for="action in actions">
<button <button
type="button"
class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)" @click="onClickAction(action.play_path)"
class="js-manual-action-link no-btn"> :class="{ 'disabled': isActionDisabled(action) }"
:disabled="isActionDisabled(action)">
${playIconSvg} ${playIconSvg}
<span> <span>
{{action.name}} {{action.name}}
...@@ -74,7 +92,6 @@ export default { ...@@ -74,7 +92,6 @@ export default {
</button> </button>
</li> </li>
</ul> </ul>
</button>
</div> </div>
`, `,
}; };
...@@ -7,6 +7,7 @@ import RollbackComponent from './environment_rollback'; ...@@ -7,6 +7,7 @@ import RollbackComponent from './environment_rollback';
import TerminalButtonComponent from './environment_terminal_button'; import TerminalButtonComponent from './environment_terminal_button';
import MonitoringButtonComponent from './environment_monitoring'; import MonitoringButtonComponent from './environment_monitoring';
import CommitComponent from '../../vue_shared/components/commit'; import CommitComponent from '../../vue_shared/components/commit';
import eventHub from '../event_hub';
/** /**
* Envrionment Item Component * Envrionment Item Component
...@@ -141,6 +142,7 @@ export default { ...@@ -141,6 +142,7 @@ export default {
const parsedAction = { const parsedAction = {
name: gl.text.humanize(action.name), name: gl.text.humanize(action.name),
play_path: action.play_path, play_path: action.play_path,
playable: action.playable,
}; };
return parsedAction; return parsedAction;
}); });
...@@ -410,7 +412,6 @@ export default { ...@@ -410,7 +412,6 @@ export default {
folderUrl() { folderUrl() {
return `${window.location.pathname}/folders/${this.model.folderName}`; return `${window.location.pathname}/folders/${this.model.folderName}`;
}, },
}, },
/** /**
...@@ -428,15 +429,37 @@ export default { ...@@ -428,15 +429,37 @@ export default {
return true; return true;
}, },
methods: {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model, this.folderUrl);
},
},
template: ` template: `
<tr> <tr :class="{ 'js-child-row': model.isChildren }">
<td> <td>
<a v-if="!model.isFolder" <a v-if="!model.isFolder"
class="environment-name" class="environment-name"
:class="{ 'prepend-left-default': model.isChildren }"
:href="environmentPath"> :href="environmentPath">
{{model.name}} {{model.name}}
</a> </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"> <span class="folder-icon">
<i class="fa fa-folder" aria-hidden="true"></i> <i class="fa fa-folder" aria-hidden="true"></i>
</span> </span>
...@@ -448,7 +471,7 @@ export default { ...@@ -448,7 +471,7 @@ export default {
<span class="badge"> <span class="badge">
{{model.size}} {{model.size}}
</span> </span>
</a> </span>
</td> </td>
<td class="deployment-column"> <td class="deployment-column">
......
...@@ -31,6 +31,18 @@ export default { ...@@ -31,6 +31,18 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
isLoadingFolderContent: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
},
}, },
template: ` template: `
...@@ -53,6 +65,31 @@ export default { ...@@ -53,6 +65,31 @@ export default {
:can-create-deployment="canCreateDeployment" :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment" :can-read-environment="canReadEnvironment"
:service="service"></tr> :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> </template>
</tbody> </tbody>
</table> </table>
......
...@@ -7,6 +7,7 @@ Vue.use(VueResource); ...@@ -7,6 +7,7 @@ Vue.use(VueResource);
export default class EnvironmentsService { export default class EnvironmentsService {
constructor(endpoint) { constructor(endpoint) {
this.environments = Vue.resource(endpoint); this.environments = Vue.resource(endpoint);
this.folderResults = 3;
} }
get(scope, page) { get(scope, page) {
...@@ -16,4 +17,8 @@ export default class EnvironmentsService { ...@@ -16,4 +17,8 @@ export default class EnvironmentsService {
postAction(endpoint) { postAction(endpoint) {
return Vue.http.post(endpoint, {}, { emulateJSON: true }); 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 { ...@@ -38,7 +38,12 @@ export default class EnvironmentsStore {
let filtered = {}; let filtered = {};
if (env.size > 1) { 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) { if (env.latest) {
...@@ -85,4 +90,67 @@ export default class EnvironmentsStore { ...@@ -85,4 +90,67 @@ export default class EnvironmentsStore {
this.state.stoppedCounter = count; this.state.stoppedCounter = count;
return 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'); ...@@ -56,7 +56,7 @@ require('./filtered_search_dropdown');
renderContent() { renderContent() {
const dropdownData = []; 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; const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) { if (icon && hint && tag) {
dropdownData.push( dropdownData.push(
......
...@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container'; ...@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
} }
}); });
return values.join(' '); return values
.map(value => value.trim())
.join(' ');
} }
static getSearchInput(filteredSearchInput) { static getSearchInput(filteredSearchInput) {
......
import Vue from 'vue';
export default new Vue();
/* global Flash */
import FilteredSearchContainer from './container'; 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 { class FilteredSearchManager {
constructor(page) { constructor(page) {
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search'); this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container'); this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; 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) { if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer; this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); 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.bindEvents();
this.loadSearchParamsFromURL(); this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown(); this.dropdownManager.setDropdown();
...@@ -25,6 +63,10 @@ import FilteredSearchContainer from './container'; ...@@ -25,6 +63,10 @@ import FilteredSearchContainer from './container';
cleanup() { cleanup() {
this.unbindEvents(); this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper); document.removeEventListener('beforeunload', this.cleanupWrapper);
if (this.recentSearchesRoot) {
this.recentSearchesRoot.destroy();
}
} }
bindEvents() { bindEvents() {
...@@ -34,7 +76,7 @@ import FilteredSearchContainer from './container'; ...@@ -34,7 +76,7 @@ import FilteredSearchContainer from './container';
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this); this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this); this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.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.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
...@@ -42,8 +84,8 @@ import FilteredSearchContainer from './container'; ...@@ -42,8 +84,8 @@ import FilteredSearchContainer from './container';
this.tokenChange = this.tokenChange.bind(this); this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.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.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
...@@ -56,11 +98,12 @@ import FilteredSearchContainer from './container'; ...@@ -56,11 +98,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); 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', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper); document.addEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
} }
unbindEvents() { unbindEvents() {
...@@ -76,11 +119,12 @@ import FilteredSearchContainer from './container'; ...@@ -76,11 +119,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); 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', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper); document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
} }
checkForBackspace(e) { checkForBackspace(e) {
...@@ -131,7 +175,7 @@ import FilteredSearchContainer from './container'; ...@@ -131,7 +175,7 @@ import FilteredSearchContainer from './container';
} }
addInputContainerFocus() { addInputContainerFocus() {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
if (inputContainer) { if (inputContainer) {
inputContainer.classList.add('focus'); inputContainer.classList.add('focus');
...@@ -139,7 +183,7 @@ import FilteredSearchContainer from './container'; ...@@ -139,7 +183,7 @@ import FilteredSearchContainer from './container';
} }
removeInputContainerFocus(e) { 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 isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
...@@ -161,7 +205,7 @@ import FilteredSearchContainer from './container'; ...@@ -161,7 +205,7 @@ import FilteredSearchContainer from './container';
} }
unselectEditTokens(e) { 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 isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container'); const isElementTokensContainer = e.target.classList.contains('tokens-container');
...@@ -215,9 +259,12 @@ import FilteredSearchContainer from './container'; ...@@ -215,9 +259,12 @@ import FilteredSearchContainer from './container';
} }
} }
clearSearch(e) { onClearSearch(e) {
e.preventDefault(); e.preventDefault();
this.clearSearch();
}
clearSearch() {
this.filteredSearchInput.value = ''; this.filteredSearchInput.value = '';
const removeElements = []; const removeElements = [];
...@@ -289,6 +336,17 @@ import FilteredSearchContainer from './container'; ...@@ -289,6 +336,17 @@ import FilteredSearchContainer from './container';
this.search(); 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() { loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray(); const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams(); const usernameParams = this.getUsernameParams();
...@@ -343,6 +401,8 @@ import FilteredSearchContainer from './container'; ...@@ -343,6 +401,8 @@ import FilteredSearchContainer from './container';
} }
}); });
this.saveCurrentSearchQuery();
if (hasFilteredSearch) { if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden'); this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder(); this.handleInputPlaceholder();
...@@ -351,8 +411,12 @@ import FilteredSearchContainer from './container'; ...@@ -351,8 +411,12 @@ import FilteredSearchContainer from './container';
search() { search() {
const paths = []; const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken } const { tokens, searchToken }
= this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery()); = this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened'; const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
...@@ -416,6 +480,13 @@ import FilteredSearchContainer from './container'; ...@@ -416,6 +480,13 @@ import FilteredSearchContainer from './container';
currentDropdownRef.dispatchInputEvent(); currentDropdownRef.dispatchInputEvent();
} }
} }
onrecentSearchesItemSelected(text) {
this.clearSearch();
this.filteredSearchInput.value = text;
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
} }
window.gl = window.gl || {}; 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() { ...@@ -45,14 +45,14 @@ window.GroupsSelect = (function() {
page, page,
per_page: GroupsSelect.PER_PAGE, per_page: GroupsSelect.PER_PAGE,
all_available, all_available,
skip_groups,
}; };
}, },
results: function (data, page) { results: function (data, page) {
if (data.length) return { results: [] }; 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 more = data.pagination ? data.pagination.more : false;
const results = groups.filter(group => skip_groups.indexOf(group.id) === -1);
return { return {
results, 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 @@ ...@@ -2,6 +2,8 @@
(function() { (function() {
(function(w) { (function(w) {
var base; var base;
const faviconEl = document.getElementById('favicon');
const originalFavicon = faviconEl ? faviconEl.getAttribute('href') : null;
w.gl || (w.gl = {}); w.gl || (w.gl = {});
(base = w.gl).utils || (base.utils = {}); (base = w.gl).utils || (base.utils = {});
w.gl.utils.isInGroupsPage = function() { w.gl.utils.isInGroupsPage = function() {
...@@ -361,5 +363,34 @@ ...@@ -361,5 +363,34 @@
fn(next, stop); 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); })(window);
}).call(window); }).call(window);
...@@ -38,11 +38,13 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; ...@@ -38,11 +38,13 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
function MergeRequestWidget(opts) { function MergeRequestWidget(opts) {
// Initialize MergeRequestWidget behavior // Initialize MergeRequestWidget behavior
// //
// check_enable - Boolean, whether to check automerge status // check_enable - Boolean, whether to check automerge status
// merge_check_url - String, URL to use 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 // 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 = opts;
this.opts.pipeline_status_url = `${this.opts.pipeline_status_url}.json`;
this.$widgetBody = $('.mr-widget-body'); this.$widgetBody = $('.mr-widget-body');
$('#modal_merge_info').modal({ $('#modal_merge_info').modal({
show: false show: false
...@@ -159,6 +161,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; ...@@ -159,6 +161,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
_this.status = data.status; _this.status = data.status;
_this.hasCi = data.has_ci; _this.hasCi = data.has_ci;
_this.updateMergeButton(_this.status, _this.hasCi); _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.environments && data.environments.length) _this.renderEnvironments(data.environments);
if (data.status !== _this.opts.ci_status || if (data.status !== _this.opts.ci_status ||
data.sha !== _this.opts.ci_sha || data.sha !== _this.opts.ci_sha ||
......
...@@ -6,7 +6,10 @@ import statusCodes from '~/lib/utils/http_status'; ...@@ -6,7 +6,10 @@ import statusCodes from '~/lib/utils/http_status';
import { formatRelevantDigits } from '~/lib/utils/number_utils'; import { formatRelevantDigits } from '~/lib/utils/number_utils';
import '../flash'; import '../flash';
const prometheusContainer = '.prometheus-container';
const prometheusParentGraphContainer = '.prometheus-graphs';
const prometheusGraphsContainer = '.prometheus-graph'; const prometheusGraphsContainer = '.prometheus-graph';
const prometheusStatesContainer = '.prometheus-state';
const metricsEndpoint = 'metrics.json'; const metricsEndpoint = 'metrics.json';
const timeFormat = d3.time.format('%H:%M'); const timeFormat = d3.time.format('%H:%M');
const dayFormat = d3.time.format('%b %e, %a'); const dayFormat = d3.time.format('%b %e, %a');
...@@ -14,19 +17,30 @@ const bisectDate = d3.bisector(d => d.time).left; ...@@ -14,19 +17,30 @@ const bisectDate = d3.bisector(d => d.time).left;
const extraAddedWidthParent = 100; const extraAddedWidthParent = 100;
class PrometheusGraph { class PrometheusGraph {
constructor() { constructor() {
this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; const $prometheusContainer = $(prometheusContainer);
this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; const hasMetrics = $prometheusContainer.data('has-metrics');
const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + this.docLink = $prometheusContainer.data('doc-link');
extraAddedWidthParent; this.integrationLink = $prometheusContainer.data('prometheus-integration');
this.originalWidth = parentContainerWidth;
this.originalHeight = 330; $(document).ajaxError(() => {});
this.width = parentContainerWidth - this.margin.left - this.margin.right;
this.height = this.originalHeight - this.margin.top - this.margin.bottom; if (hasMetrics) {
this.backOffRequestCounter = 0; this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
this.configureGraph(); this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
this.init(); 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() { createGraph() {
...@@ -40,8 +54,19 @@ class PrometheusGraph { ...@@ -40,8 +54,19 @@ class PrometheusGraph {
init() { init() {
this.getData().then((metricsResponse) => { this.getData().then((metricsResponse) => {
if (Object.keys(metricsResponse).length === 0) { let enoughData = true;
new Flash('Empty metrics', 'alert'); 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 { } else {
this.transformData(metricsResponse); this.transformData(metricsResponse);
this.createGraph(); this.createGraph();
...@@ -345,14 +370,17 @@ class PrometheusGraph { ...@@ -345,14 +370,17 @@ class PrometheusGraph {
} }
return resp.metrics; return resp.metrics;
}) })
.catch(() => new Flash('An error occurred while fetching metrics.', 'alert')); .catch(() => {
this.state = '.js-unable-to-connect';
this.updateState();
});
} }
transformData(metricsResponse) { transformData(metricsResponse) {
Object.keys(metricsResponse.metrics).forEach((key) => { Object.keys(metricsResponse.metrics).forEach((key) => {
if (key === 'cpu_values' || key === 'memory_values') { if (key === 'cpu_values' || key === 'memory_values') {
const metricValues = (metricsResponse.metrics[key])[0]; const metricValues = (metricsResponse.metrics[key])[0];
if (typeof metricValues !== 'undefined') { if (metricValues !== undefined) {
this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({
time: new Date(metric[0] * 1000), time: new Date(metric[0] * 1000),
value: metric[1], value: metric[1],
...@@ -361,6 +389,13 @@ class PrometheusGraph { ...@@ -361,6 +389,13 @@ class PrometheusGraph {
} }
}); });
} }
updateState() {
const $statesContainer = $(prometheusStatesContainer);
$(prometheusParentGraphContainer).hide();
$(`${this.state}`, $statesContainer).removeClass('hidden');
$(prometheusStatesContainer).show();
}
} }
export default PrometheusGraph; export default PrometheusGraph;
...@@ -9,6 +9,10 @@ require('./lib/utils/bootstrap_linked_tabs'); ...@@ -9,6 +9,10 @@ require('./lib/utils/bootstrap_linked_tabs');
new global.LinkedTabs(options.tabsOptions); new global.LinkedTabs(options.tabsOptions);
} }
if (options.pipelineStatusUrl) {
gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
}
this.addMarginToBuildColumns(); this.addMarginToBuildColumns();
} }
......
...@@ -38,6 +38,14 @@ export default { ...@@ -38,6 +38,14 @@ export default {
new Flash('An error occured while making the request.'); new Flash('An error occured while making the request.');
}); });
}, },
isActionDisabled(action) {
if (action.playable === undefined) {
return false;
}
return !action.playable;
},
}, },
template: ` template: `
...@@ -51,16 +59,23 @@ export default { ...@@ -51,16 +59,23 @@ export default {
aria-label="Manual job" aria-label="Manual job"
:disabled="isLoading"> :disabled="isLoading">
${playIconSvg} ${playIconSvg}
<i class="fa fa-caret-down" aria-hidden="true"></i> <i
<i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> class="fa fa-caret-down"
aria-hidden="true" />
<i
v-if="isLoading"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
</button> </button>
<ul class="dropdown-menu dropdown-menu-align-right"> <ul class="dropdown-menu dropdown-menu-align-right">
<li v-for="action in actions"> <li v-for="action in actions">
<button <button
type="button" type="button"
class="js-pipeline-action-link no-btn" class="js-pipeline-action-link no-btn btn"
@click="onClickAction(action.path)"> @click="onClickAction(action.path)"
:class="{ 'disabled': isActionDisabled(action) }"
:disabled="isActionDisabled(action)">
${playIconSvg} ${playIconSvg}
<span>{{action.name}}</span> <span>{{action.name}}</span>
</button> </button>
......
/* eslint-disable no-underscore-dangle*/ /* eslint-disable no-underscore-dangle*/
import '../../vue_realtime_listener'; import VueRealtimeListener from '../../vue_realtime_listener';
export default class PipelinesStore { export default class PipelinesStore {
constructor() { constructor() {
...@@ -56,6 +56,6 @@ export default class PipelinesStore { ...@@ -56,6 +56,6 @@ export default class PipelinesStore {
const removeIntervals = () => clearInterval(this.timeLoopInterval); const removeIntervals = () => clearInterval(this.timeLoopInterval);
const startIntervals = () => startTimeLoops(); const startIntervals = () => startTimeLoops();
gl.VueRealtimeListener(removeIntervals, startIntervals); VueRealtimeListener(removeIntervals, startIntervals);
} }
} }
/* eslint-disable no-param-reassign */ export default (removeIntervals, startIntervals) => {
window.removeEventListener('focus', startIntervals);
((gl) => { window.removeEventListener('blur', removeIntervals);
gl.VueRealtimeListener = (removeIntervals, startIntervals) => { window.removeEventListener('onbeforeload', removeIntervals);
const removeAll = () => {
removeIntervals(); window.addEventListener('focus', startIntervals);
window.removeEventListener('beforeunload', removeIntervals); window.addEventListener('blur', removeIntervals);
window.removeEventListener('focus', startIntervals); window.addEventListener('onbeforeload', removeIntervals);
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 = {}));
...@@ -91,7 +91,7 @@ ...@@ -91,7 +91,7 @@
.award-menu-holder { .award-menu-holder {
display: inline-block; display: inline-block;
position: relative; position: absolute;
.tooltip { .tooltip {
white-space: nowrap; white-space: nowrap;
...@@ -117,11 +117,41 @@ ...@@ -117,11 +117,41 @@
&.active, &.active,
&:hover, &:hover,
&:active { &:active,
&.is-active {
background-color: $row-hover; background-color: $row-hover;
border-color: $row-hover-border; border-color: $row-hover-border;
box-shadow: none; box-shadow: none;
outline: 0; 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 { &.btn {
...@@ -162,9 +192,33 @@ ...@@ -162,9 +192,33 @@
color: $border-gray-normal; color: $border-gray-normal;
margin-top: 1px; margin-top: 1px;
padding: 0 2px; 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 { .award-control-text {
vertical-align: middle; vertical-align: middle;
} }
} }
.note-awards .award-control-icon-positive {
left: 6px;
}
...@@ -292,6 +292,10 @@ ...@@ -292,6 +292,10 @@
} }
@media(min-width: $screen-xs-max) { @media(min-width: $screen-xs-max) {
&.merge-requests .text-content {
margin-top: 40px;
}
&.labels .text-content { &.labels .text-content {
margin-top: 70px; margin-top: 70px;
} }
......
...@@ -177,10 +177,6 @@ ...@@ -177,10 +177,6 @@
border-radius: $border-radius-base; border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color; box-shadow: 0 2px 4px $dropdown-shadow-color;
.filtered-search-input-container & {
max-width: 280px;
}
&.is-loading { &.is-loading {
.dropdown-content { .dropdown-content {
display: none; display: none;
...@@ -467,6 +463,11 @@ ...@@ -467,6 +463,11 @@
overflow-y: auto; overflow-y: auto;
} }
.dropdown-info-note {
color: $gl-text-color-secondary;
text-align: center;
}
.dropdown-footer { .dropdown-footer {
padding-top: 10px; padding-top: 10px;
margin-top: 10px; margin-top: 10px;
......
...@@ -275,3 +275,22 @@ span.idiff { ...@@ -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 @@ ...@@ -22,7 +22,6 @@
} }
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.issues-filters,
.issues_bulk_update { .issues_bulk_update {
.dropdown-menu-toggle { .dropdown-menu-toggle {
width: 132px; width: 132px;
...@@ -56,7 +55,7 @@ ...@@ -56,7 +55,7 @@
} }
} }
.filtered-search-container { .filtered-search-wrapper {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
...@@ -151,11 +150,13 @@ ...@@ -151,11 +150,13 @@
width: 100%; width: 100%;
} }
.filtered-search-input-container { .filtered-search-box {
position: relative;
flex: 1;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
position: relative;
width: 100%; width: 100%;
min-width: 0;
border: 1px solid $border-color; border: 1px solid $border-color;
background-color: $white-light; background-color: $white-light;
...@@ -163,14 +164,6 @@ ...@@ -163,14 +164,6 @@
-webkit-flex: 1 1 auto; -webkit-flex: 1 1 auto;
flex: 1 1 auto; flex: 1 1 auto;
margin-bottom: 10px; margin-bottom: 10px;
.dropdown-menu {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
} }
&:hover { &:hover {
...@@ -229,6 +222,118 @@ ...@@ -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 { .filter-dropdown-container {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
...@@ -248,10 +353,8 @@ ...@@ -248,10 +353,8 @@
} }
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
.issues-details-filters { .issue-bulk-update-dropdown-toggle {
.dropdown-menu-toggle { width: 100px;
width: 100px;
}
} }
} }
......
...@@ -16,6 +16,8 @@ body.modal-open { ...@@ -16,6 +16,8 @@ body.modal-open {
overflow: hidden; overflow: hidden;
} }
.modal .modal-dialog { @media (min-width: $screen-md-min) {
width: 860px; .modal-dialog {
width: 860px;
}
} }
...@@ -293,6 +293,8 @@ $badge-color: $gl-text-color-secondary; ...@@ -293,6 +293,8 @@ $badge-color: $gl-text-color-secondary;
* Award emoji * Award emoji
*/ */
$award-emoji-menu-shadow: rgba(0,0,0,.175); $award-emoji-menu-shadow: rgba(0,0,0,.175);
$award-emoji-positive-add-bg: #fed159;
$award-emoji-positive-add-lines: #bb9c13;
/* /*
* Search Box * 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 @@ ...@@ -233,6 +233,15 @@
stroke-width: 1; stroke-width: 1;
} }
.prometheus-state {
margin-top: 10px;
display: none;
.state-button-section {
margin-top: 10px;
}
}
.environments-actions { .environments-actions {
.external-url, .external-url,
.monitoring-url, .monitoring-url,
......
...@@ -4,14 +4,14 @@ ...@@ -4,14 +4,14 @@
*/ */
.event-item { .event-item {
font-size: $gl-font-size; 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; border-bottom: 1px solid $white-normal;
color: $list-text-color; color: $list-text-color;
position: relative;
&.event-inline { &.event-inline {
.avatar { .profile-icon {
position: relative; top: 20px;
top: -2px;
} }
.event-title, .event-title,
...@@ -24,8 +24,28 @@ ...@@ -24,8 +24,28 @@
color: $gl-text-color; color: $gl-text-color;
} }
.avatar { .profile-icon {
margin-left: -($gl-avatar-size + $gl-padding-top); 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 { .event-title {
...@@ -163,7 +183,7 @@ ...@@ -163,7 +183,7 @@
max-width: 100%; max-width: 100%;
} }
.avatar { .profile-icon {
display: none; display: none;
} }
......
...@@ -329,8 +329,6 @@ ...@@ -329,8 +329,6 @@
} }
#modal_merge_info .modal-dialog { #modal_merge_info .modal-dialog {
width: 600px;
.dark { .dark {
margin-right: 40px; margin-right: 40px;
} }
......
...@@ -398,13 +398,50 @@ ul.notes { ...@@ -398,13 +398,50 @@ ul.notes {
font-size: 17px; 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 { .danger-highlight {
color: $gl-text-red; color: $gl-text-red;
} }
.link-highlight { .link-highlight {
color: $gl-link-color; 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 { ...@@ -508,7 +545,6 @@ ul.notes {
} }
.line-resolve-all-container { .line-resolve-all-container {
.btn-group { .btn-group {
margin-left: -4px; margin-left: -4px;
} }
...@@ -537,7 +573,6 @@ ul.notes { ...@@ -537,7 +573,6 @@ ul.notes {
fill: $gray-darkest; fill: $gray-darkest;
} }
} }
} }
.line-resolve-all { .line-resolve-all {
......
...@@ -230,6 +230,14 @@ ...@@ -230,6 +230,14 @@
font-size: 0; font-size: 0;
} }
.fade-right {
right: 0;
}
.fade-left {
left: 0;
}
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
.cover-block { .cover-block {
padding-top: 20px; padding-top: 20px;
......
...@@ -459,20 +459,13 @@ a.deploy-project-label { ...@@ -459,20 +459,13 @@ a.deploy-project-label {
flex-wrap: wrap; flex-wrap: wrap;
.btn { .btn {
margin: 0 10px 10px 0;
padding: 8px; padding: 8px;
margin-left: 10px;
} }
> div { > div {
margin-bottom: 10px;
padding-left: 0; padding-left: 0;
&:last-child {
margin-bottom: 0;
.btn {
margin-right: 0;
}
}
} }
} }
} }
......
...@@ -6,6 +6,8 @@ ...@@ -6,6 +6,8 @@
} }
.trigger-actions { .trigger-actions {
white-space: nowrap;
.btn { .btn {
margin-left: 10px; margin-left: 10px;
} }
......
...@@ -145,8 +145,6 @@ ...@@ -145,8 +145,6 @@
margin: 0; margin: 0;
} }
#modal-remove-blob > .modal-dialog { width: 850px; }
.blob-upload-dropzone-previews { .blob-upload-dropzone-previews {
text-align: center; text-align: center;
border: 2px; border: 2px;
......
...@@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController ...@@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController
:name, :name,
:path, :path,
:request_access_enabled, :request_access_enabled,
:visibility_level :visibility_level,
:require_two_factor_authentication,
:two_factor_grace_period
] ]
end end
end end
...@@ -3,6 +3,7 @@ class Admin::ProjectsController < Admin::ApplicationController ...@@ -3,6 +3,7 @@ class Admin::ProjectsController < Admin::ApplicationController
before_action :group, only: [:show, :transfer] before_action :group, only: [:show, :transfer]
def index def index
params[:sort] ||= 'latest_activity_desc'
@projects = Project.with_statistics @projects = Project.with_statistics
@projects = @projects.in_namespace(params[:namespace_id]) if params[:namespace_id].present? @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? @projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present?
......
...@@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base ...@@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base
include PageLayoutHelper include PageLayoutHelper
include SentryHelper include SentryHelper
include WorkhorseHelper include WorkhorseHelper
include EnforcesTwoFactorAuthentication
before_action :authenticate_user_from_private_token! before_action :authenticate_user_from_private_token!
before_action :authenticate_user! before_action :authenticate_user!
before_action :validate_user_service_ticket! before_action :validate_user_service_ticket!
before_action :check_password_expiration before_action :check_password_expiration
before_action :check_2fa_requirement
before_action :ldap_security_check before_action :ldap_security_check
before_action :sentry_context before_action :sentry_context
before_action :default_headers before_action :default_headers
...@@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base ...@@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base
end end
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 def ldap_security_check
if current_user && current_user.requires_ldap_check? if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease return unless current_user.try_obtain_ldap_lease
...@@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base ...@@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base
current_application_settings.import_sources.include?('gitlab_project') current_application_settings.import_sources.include?('gitlab_project')
end 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 # U2F (universal 2nd factor) devices need a unique identifier for the application
# to perform authentication. # to perform authentication.
# https://developers.yubico.com/U2F/App_ID.html # https://developers.yubico.com/U2F/App_ID.html
......
...@@ -7,6 +7,7 @@ module ContinueParams ...@@ -7,6 +7,7 @@ module ContinueParams
continue_params = continue_params.permit(:to, :notice, :notice_now) continue_params = continue_params.permit(:to, :notice, :notice_now)
return unless continue_params[:to] && continue_params[:to].start_with?('/') return unless continue_params[:to] && continue_params[:to].start_with?('/')
return if continue_params[:to].start_with?('//')
continue_params continue_params
end 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 class Dashboard::ProjectsController < Dashboard::ApplicationController
include FilterProjects include ParamsBackwardCompatibility
before_action :set_non_archived_param
before_action :default_sorting
def index def index
@projects = load_projects(current_user.authorized_projects) @projects = load_projects(params.merge(non_public: true)).page(params[:page])
@projects = @projects.sort(@sort = params[:sort])
@projects = @projects.page(params[:page])
respond_to do |format| respond_to do |format|
format.html { @last_push = current_user.recent_push } format.html { @last_push = current_user.recent_push }
...@@ -21,10 +22,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController ...@@ -21,10 +22,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end end
def starred def starred
@projects = load_projects(current_user.viewable_starred_projects) @projects = load_projects(params.merge(starred: true)).
@projects = @projects.includes(:forked_from_project, :tags) includes(:forked_from_project, :tags).page(params[:page])
@projects = @projects.sort(@sort = params[:sort])
@projects = @projects.page(params[:page])
@last_push = current_user.recent_push @last_push = current_user.recent_push
@groups = [] @groups = []
...@@ -41,14 +40,18 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController ...@@ -41,14 +40,18 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
private private
def load_projects(base_scope) def default_sorting
projects = base_scope.sorted_by_activity.includes(:route, namespace: :route) 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 end
def load_events 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 = event_filter.apply_filter(@events).with_associations
@events = @events.limit(20).offset(params[:offset] || 0) @events = @events.limit(20).offset(params[:offset] || 0)
end end
......
...@@ -7,7 +7,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController ...@@ -7,7 +7,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
@sort = params[:sort] @sort = params[:sort]
@todos = @todos.page(params[:page]) @todos = @todos.page(params[:page])
if @todos.out_of_range? && @todos.total_pages != 0 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
end end
......
class Explore::ProjectsController < Explore::ApplicationController class Explore::ProjectsController < Explore::ApplicationController
include FilterProjects include ParamsBackwardCompatibility
before_action :set_non_archived_param
def index def index
@projects = load_projects params[:sort] ||= 'latest_activity_desc'
@tags = @projects.tags_on(:tags) @sort = params[:sort]
@projects = @projects.tagged_with(params[:tag]) if params[:tag].present? @projects = load_projects.page(params[:page])
@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])
respond_to do |format| respond_to do |format|
format.html format.html
...@@ -21,10 +19,9 @@ class Explore::ProjectsController < Explore::ApplicationController ...@@ -21,10 +19,9 @@ class Explore::ProjectsController < Explore::ApplicationController
end end
def trending def trending
@projects = load_projects(Project.trending) params[:trending] = true
@projects = filter_projects(@projects) @sort = params[:sort]
@projects = @projects.sort(@sort = params[:sort]) @projects = load_projects.page(params[:page])
@projects = @projects.page(params[:page])
respond_to do |format| respond_to do |format|
format.html format.html
...@@ -37,10 +34,7 @@ class Explore::ProjectsController < Explore::ApplicationController ...@@ -37,10 +34,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end end
def starred def starred
@projects = load_projects @projects = load_projects.reorder('star_count DESC').page(params[:page])
@projects = filter_projects(@projects)
@projects = @projects.reorder('star_count DESC')
@projects = @projects.page(params[:page])
respond_to do |format| respond_to do |format|
format.html format.html
...@@ -52,10 +46,10 @@ class Explore::ProjectsController < Explore::ApplicationController ...@@ -52,10 +46,10 @@ class Explore::ProjectsController < Explore::ApplicationController
end end
end end
protected private
def load_projects(base_scope = nil) def load_projects
base_scope ||= ProjectsFinder.new.execute(current_user) ProjectsFinder.new(current_user: current_user, params: params).
base_scope.includes(:route, namespace: :route) execute.includes(:route, namespace: :route)
end end
end end
...@@ -10,6 +10,7 @@ class Groups::ApplicationController < ApplicationController ...@@ -10,6 +10,7 @@ class Groups::ApplicationController < ApplicationController
unless @group unless @group
id = params[:group_id] || params[:id] id = params[:group_id] || params[:id]
@group = Group.find_by_full_path(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) unless @group && can?(current_user, :read_group, @group)
@group = nil @group = nil
...@@ -26,7 +27,7 @@ class Groups::ApplicationController < ApplicationController ...@@ -26,7 +27,7 @@ class Groups::ApplicationController < ApplicationController
end end
def group_projects def group_projects
@projects ||= GroupProjectsFinder.new(group).execute(current_user) @projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute
end end
def authorize_admin_group! def authorize_admin_group!
......
class GroupsController < Groups::ApplicationController class GroupsController < Groups::ApplicationController
include FilterProjects
include IssuesAction include IssuesAction
include MergeRequestsAction include MergeRequestsAction
include ParamsBackwardCompatibility
respond_to :html respond_to :html
...@@ -105,15 +105,16 @@ class GroupsController < Groups::ApplicationController ...@@ -105,15 +105,16 @@ class GroupsController < Groups::ApplicationController
protected protected
def setup_projects def setup_projects
set_non_archived_param
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
options = {} options = {}
options[:only_owned] = true if params[:shared] == '0' options[:only_owned] = true if params[:shared] == '0'
options[:only_shared] = true if params[:shared] == '1' 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.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? @projects = @projects.page(params[:page]) if params[:name].blank?
end end
...@@ -150,7 +151,9 @@ class GroupsController < Groups::ApplicationController ...@@ -150,7 +151,9 @@ class GroupsController < Groups::ApplicationController
:visibility_level, :visibility_level,
:parent_id, :parent_id,
:create_chat_team, :create_chat_team,
:chat_team_name :chat_team_name,
:require_two_factor_authentication,
:two_factor_grace_period
] ]
end end
......
class Import::BaseController < ApplicationController class Import::BaseController < ApplicationController
private private
def find_or_create_namespace(name, owner) def find_or_create_namespace(names, owner)
return current_user.namespace if name == owner return current_user.namespace if names == owner
return current_user.namespace unless current_user.can_create_group? return current_user.namespace unless current_user.can_create_group?
begin names = params[:target_namespace].presence || names
name = params[:target_namespace].presence || name full_path_namespace = Namespace.find_by_full_path(names)
namespace = Group.create!(name: name, path: name, owner: current_user)
namespace.add_owner(current_user) return full_path_namespace if full_path_namespace
namespace
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid names.split('/').inject(nil) do |parent, name|
Namespace.find_by_full_path(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 end
end end
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_2fa_requirement skip_before_action :check_two_factor_requirement
def show def show
unless current_user.otp_secret unless current_user.otp_secret
...@@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.save! if current_user.changed? current_user.save! if current_user.changed?
if two_factor_authentication_required? && !current_user.two_factor_enabled? if two_factor_authentication_required? && !current_user.two_factor_enabled?
if two_factor_grace_period_expired? two_factor_authentication_reason(
flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.' global: lambda do
else 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 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
end end
...@@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
if two_factor_grace_period_expired? if two_factor_grace_period_expired?
redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup' redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
else 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 redirect_to root_path
end end
end end
......
...@@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController
# Raised when given an invalid file path # Raised when given an invalid file path
InvalidPathError = Class.new(StandardError) InvalidPathError = Class.new(StandardError)
prepend_before_action :authenticate_user!, only: [:edit]
before_action :require_non_empty_project, except: [:new, :create] before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code! 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 :assign_blob_vars
before_action :commit, except: [:new, :create] before_action :commit, except: [:new, :create]
before_action :blob, except: [:new, :create] before_action :blob, except: [:new, :create]
...@@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController
end end
def edit def edit
blob.load_all_data!(@repository) if can_collaborate_with_project?
blob.load_all_data!(@repository)
else
redirect_to action: 'show'
end
end end
def update def update
......
...@@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController ...@@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id) @builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline @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 end
def trace def trace
respond_to do |format| build.trace.read do |stream|
format.json do respond_to do |format|
state = params[:state].presence format.json do
render json: @build.trace_with_state(state: state). result = {
merge!(id: @build.id, status: @build.status) 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 end
end end
...@@ -86,10 +86,12 @@ class Projects::BuildsController < Projects::ApplicationController ...@@ -86,10 +86,12 @@ class Projects::BuildsController < Projects::ApplicationController
end end
def raw def raw
if @build.has_trace_file? build.trace.read do |stream|
send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline' if stream.file?
else send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
render_404 else
render_404
end
end 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 ...@@ -9,7 +9,7 @@ class Projects::ForksController < Projects::ApplicationController
def index def index
base_query = project.forks.includes(:creator) 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 @total_forks_count = base_query.size
@private_forks_count = @total_forks_count - @forks.size @private_forks_count = @total_forks_count - @forks.size
@public_forks_count = @total_forks_count - @private_forks_count @public_forks_count = @total_forks_count - @private_forks_count
......
...@@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled before_action :module_enabled
before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, 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 # Allow read any issue
before_action :authorize_read_issue!, only: [:show] before_action :authorize_read_issue!, only: [:show, :rendered_title]
# Allow write(create) issue # Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create] before_action :authorize_create_issue!, only: [:new, :create]
...@@ -31,7 +31,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -31,7 +31,7 @@ class Projects::IssuesController < Projects::ApplicationController
@issuable_meta_data = issuable_meta_data(@issues, @collection_type) @issuable_meta_data = issuable_meta_data(@issues, @collection_type)
if @issues.out_of_range? && @issues.total_pages != 0 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 end
if params[:label_name].present? if params[:label_name].present?
...@@ -200,6 +200,11 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -200,6 +200,11 @@ class Projects::IssuesController < Projects::ApplicationController
end end
end end
def rendered_title
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: { title: view_context.markdown_field(@issue, :title) }
end
protected protected
def issue def issue
......
...@@ -43,7 +43,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -43,7 +43,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type) @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type)
if @merge_requests.out_of_range? && @merge_requests.total_pages != 0 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 end
if params[:label_name].present? if params[:label_name].present?
...@@ -452,7 +452,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -452,7 +452,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
if pipeline if pipeline
status = pipeline.status status = pipeline.status
coverage = pipeline.try(:coverage) coverage = pipeline.coverage
status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? 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 ...@@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController
include Devise::Controllers::Rememberable include Devise::Controllers::Rememberable
include Recaptcha::ClientHelper 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 :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor, prepend_before_action :authenticate_with_two_factor,
......
...@@ -140,6 +140,6 @@ class UsersController < ApplicationController ...@@ -140,6 +140,6 @@ class UsersController < ApplicationController
end end
def projects_for_current_user def projects_for_current_user
ProjectsFinder.new.execute(current_user) ProjectsFinder.new(current_user: current_user).execute
end end
end end
class GroupProjectsFinder < UnionFinder # GroupProjectsFinder
def initialize(group, options = {}) #
# 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 @group = group
@options = options @options = options
end end
def execute(current_user = nil)
segments = group_projects(current_user)
find_union(segments, Project)
end
private private
def group_projects(current_user) def init_collection
only_owned = @options.fetch(:only_owned, false) only_owned = options.fetch(:only_owned, false)
only_shared = @options.fetch(:only_shared, false) only_shared = options.fetch(:only_shared, false)
projects = [] projects = []
if current_user if current_user
if @group.users.include?(current_user) if group.users.include?(current_user)
projects << @group.projects unless only_shared projects << group.projects unless only_shared
projects << @group.shared_projects unless only_owned projects << group.shared_projects unless only_owned
else else
unless only_shared unless only_shared
projects << @group.projects.visible_to_user(current_user) projects << group.projects.visible_to_user(current_user)
projects << @group.projects.public_to_user(current_user) projects << group.projects.public_to_user(current_user)
end end
unless only_owned unless only_owned
projects << @group.shared_projects.visible_to_user(current_user) projects << group.shared_projects.visible_to_user(current_user)
projects << @group.shared_projects.public_to_user(current_user) projects << group.shared_projects.public_to_user(current_user)
end end
end end
else else
projects << @group.projects.public_only unless only_shared projects << group.projects.public_only unless only_shared
projects << @group.shared_projects.public_only unless only_owned projects << group.shared_projects.public_only unless only_owned
end end
projects projects
end end
def union(items)
find_union(items, Project)
end
end end
...@@ -116,9 +116,9 @@ class IssuableFinder ...@@ -116,9 +116,9 @@ class IssuableFinder
if current_user && params[:authorized_only].presence && !current_user_related? if current_user && params[:authorized_only].presence && !current_user_related?
current_user.authorized_projects current_user.authorized_projects
elsif group elsif group
GroupProjectsFinder.new(group).execute(current_user) GroupProjectsFinder.new(group: group, current_user: current_user).execute
else 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 end
@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
...@@ -405,8 +405,4 @@ class IssuableFinder ...@@ -405,8 +405,4 @@ class IssuableFinder
def current_user_related? def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me' params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end end
def projects_finder
@projects_finder ||= ProjectsFinder.new
end
end end
...@@ -83,7 +83,7 @@ class LabelsFinder < UnionFinder ...@@ -83,7 +83,7 @@ class LabelsFinder < UnionFinder
def projects def projects
return @projects if defined?(@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.in_namespace(params[:group_id]) if group?
@projects = @projects.where(id: params[:project_ids]) if projects? @projects = @projects.where(id: params[:project_ids]) if projects?
@projects = @projects.reorder(nil) @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