Commit 9304acc3 authored by Rubén Dávila Santos's avatar Rubén Dávila Santos

Merge branch 'ce-to-ee' into 'master'

CE upstream

Closes #1655, gitlab-ce#8082, gitlab-com/infrastructure#1058, #1333, and #323

See merge request !1185
parents 0150b04f 84a80a98
......@@ -3,4 +3,4 @@ lib/gitlab/sanitizers/svg/whitelist.rb
lib/gitlab/diff/position_tracer.rb
app/controllers/projects/approver_groups_controller.rb
app/controllers/projects/approvers_controller.rb
app/policies/project_policy.rb
\ No newline at end of file
app/policies/project_policy.rb
......@@ -432,9 +432,9 @@ pages:
script:
- mv public/ .public/
- mkdir public/
- mv coverage public/coverage-ruby
- mv coverage-javascript/default/ public/coverage-javascript/
- mv eslint-report.html public/
- mv coverage/ public/coverage-ruby/ || true
- mv coverage-javascript/default/ public/coverage-javascript/ || true
- mv eslint-report.html public/ || true
artifacts:
paths:
- public
......
......@@ -2,6 +2,21 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 8.16.4 (2017-02-02)
- Support non-ASCII characters in GFM autocomplete. !8729
- Fix search bar search param encoding. !8753
- Fix project name label's for reference in project settings. !8795
- Fix filtering with multiple words. !8830
- Fixed services form cancel not redirecting back the integrations settings view. !8843
- Fix filtering usernames with multiple words. !8851
- Improve performance of slash commands. !8876
- Remove old project members when retrying an export.
- Fix permalink discussion note being collapsed.
- Add project ID index to `project_authorizations` table to optimize queries.
- Check public snippets for spam.
- 19164 Add settings dropdown to mobile screens.
## 8.16.3 (2017-01-27)
- Add caching of droplab ajax requests. !8725
......
......@@ -15,6 +15,7 @@
- [Issue weight](#issue-weight)
- [Regression issues](#regression-issues)
- [Technical debt](#technical-debt)
- [Stewardship][#stewardship]
- [Merge requests](#merge-requests)
- [Merge request guidelines](#merge-request-guidelines)
- [Contribution acceptance criteria](#contribution-acceptance-criteria)
......@@ -230,6 +231,21 @@ for a release by the appropriate person.
Make sure to mention the merge request that the `technical debt` issue is
associated with in the description of the issue.
### Stewardship
For issues related to the open source stewardship of GitLab,
there is the ~"stewardship" label.
This label is to be used for issues in which the stewardship of GitLab
is a topic of discussion. For instance if GitLab Inc. is planning to remove
features from GitLab CE to make exclusive in GitLab EE, related issues
would be labelled with ~"stewardship".
A recent example of this was the issue for
[bringing the time tracking API to GitLab CE][time-tracking-issue].
[time-tracking-issue]: https://gitlab.com/gitlab-org/gitlab-ce/issues/25517#note_20019084
## Merge requests
We welcome merge requests with fixes and improvements to GitLab code, tests,
......
......@@ -33,7 +33,7 @@ core team members will mention this person.
### Merge request coaching
Several people from the [GitLab team][team] are helping community members to get
their contributions accepted by meeting our [Definition of done][CONTRIBUTING.md#definition-of-done].
their contributions accepted by meeting our [Definition of done](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done).
What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/.
......@@ -79,47 +79,47 @@ not be merged into any stable branches.
### Improperly formatted issue
Thanks for the issue report. Please reformat your issue to conform to the \[contributing guidelines\]\(https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker-guidelines).
Thanks for the issue report. Please reformat your issue to conform to the [contributing guidelines](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker-guidelines).
### Issue report for old version
Thanks for the issue report but we only support issues for the latest stable version of GitLab. I'm closing this issue but if you still experience this problem in the latest stable version, please open a new issue (but also reference the old issue(s)). Make sure to also include the necessary debugging information conforming to the issue tracker guidelines found in our \[contributing guidelines\]\(https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker-guidelines).
Thanks for the issue report but we only support issues for the latest stable version of GitLab. I'm closing this issue but if you still experience this problem in the latest stable version, please open a new issue (but also reference the old issue(s)). Make sure to also include the necessary debugging information conforming to the issue tracker guidelines found in our [contributing guidelines](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker-guidelines).
### Support requests and configuration questions
Thanks for your interest in GitLab. We don't use the issue tracker for support
requests and configuration questions. Please check our
\[getting help\]\(https://about.gitlab.com/getting-help/) page to see all of the available
support options. Also, have a look at the \[contribution guidelines\]\(https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md)
[getting help](https://about.gitlab.com/getting-help/) page to see all of the available
support options. Also, have a look at the [contribution guidelines](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md)
for more information.
### Code format
Please use ``` to format console output, logs, and code as it's very hard to read otherwise.
Please use \`\`\` to format console output, logs, and code as it's very hard to read otherwise.
### Issue fixed in newer version
Thanks for the issue report. This issue has already been fixed in newer versions of GitLab. Due to the size of this project and our limited resources we are only able to support the latest stable release as outlined in our \[contributing guidelines\]\(https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker). In order to get this bug fix and enjoy many new features please \[upgrade\]\(https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update). If you still experience issues at that time please open a new issue following our issue tracker guidelines found in the \[contributing guidelines\]\(https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker-guidelines).
Thanks for the issue report. This issue has already been fixed in newer versions of GitLab. Due to the size of this project and our limited resources we are only able to support the latest stable release as outlined in our [contributing guidelines](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker). In order to get this bug fix and enjoy many new features please [upgrade](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update). If you still experience issues at that time please open a new issue following our issue tracker guidelines found in the [contributing guidelines](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker-guidelines).
### Improperly formatted merge request
Thanks for your interest in improving the GitLab codebase! Please update your merge request according to the \[contributing guidelines\]\(https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#pull-request-guidelines).
Thanks for your interest in improving the GitLab codebase! Please update your merge request according to the [contributing guidelines](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#pull-request-guidelines).
### Inactivity close of an issue
It's been at least 2 weeks (and a new release) since we heard from you. I'm closing this issue but if you still experience this problem, please open a new issue (but also reference the old issue(s)). Make sure to also include the necessary debugging information conforming to the issue tracker guidelines found in our \[contributing guidelines\]\(https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker-guidelines).
It's been at least 2 weeks (and a new release) since we heard from you. I'm closing this issue but if you still experience this problem, please open a new issue (but also reference the old issue(s)). Make sure to also include the necessary debugging information conforming to the issue tracker guidelines found in our [contributing guidelines](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker-guidelines).
### Inactivity close of a merge request
This merge request has been closed because a request for more information has not been reacted to for more than 2 weeks. If you respond and conform to the merge request guidelines in our \[contributing guidelines\]\(https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#pull-requests) we will reopen this merge request.
This merge request has been closed because a request for more information has not been reacted to for more than 2 weeks. If you respond and conform to the merge request guidelines in our [contributing guidelines](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#pull-requests) we will reopen this merge request.
### Accepting merge requests
Is there an issue on the
\[issue tracker\]\(https://gitlab.com/gitlab-org/gitlab-ce/issues) that is
[issue tracker](https://gitlab.com/gitlab-org/gitlab-ce/issues) that is
similar to this? Could you please link it here?
Please be aware that new functionality that is not marked
\[accepting merge requests\]\(https://gitlab.com/gitlab-org/gitlab-ce/issues?milestone_id=&scope=all&sort=created_desc&state=opened&utf8=%E2%9C%93&assignee_id=&author_id=&milestone_title=&label_name=Accepting+Merge+Requests)
[accepting merge requests](https://gitlab.com/gitlab-org/gitlab-ce/issues?milestone_id=&scope=all&sort=created_desc&state=opened&utf8=%E2%9C%93&assignee_id=&author_id=&milestone_title=&label_name=Accepting+Merge+Requests)
might not make it into GitLab.
### Only accepting merge requests with green tests
......@@ -134,7 +134,7 @@ rebase with master to see if that solves the issue.
We are currently in the process of closing down the issue tracker on GitHub, to
prevent duplication with the GitLab.com issue tracker.
Since this is an older issue I'll be closing this for now. If you think this is
still an issue I encourage you to open it on the \[GitLab.com issue tracker\]\(https://gitlab.com/gitlab-org/gitlab-ce/issues).
still an issue I encourage you to open it on the [GitLab.com issue tracker](https://gitlab.com/gitlab-org/gitlab-ce/issues).
[team]: https://about.gitlab.com/team/
[contribution acceptance criteria]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#contribution-acceptance-criteria
......
......@@ -252,5 +252,7 @@ window.ES6Promise.polyfill();
new Aside();
// bind sidebar events
new gl.Sidebar();
gl.utils.initTimeagoTimeout();
});
}).call(this);
......@@ -17,7 +17,7 @@ require('./components/boards_selector');
require('./components/board_sidebar');
require('./components/new_list_dropdown');
require('./components/modal/index');
require('./vue_resource_interceptor');
require('../vue_shared/vue_resource_interceptor');
$(() => {
const $boardApp = document.getElementById('board-app');
......
/* global Vue */
const userFilter = require('./filters/user');
const milestoneFilter = require('./filters/milestone');
const labelFilter = require('./filters/label');
module.exports = Vue.extend({
name: 'modal-filters',
props: {
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
},
destroyed() {
gl.issueBoards.ModalStore.setDefaultFilter();
},
components: {
userFilter,
milestoneFilter,
labelFilter,
},
template: `
<div class="modal-filters">
<user-filter
dropdown-class-name="dropdown-menu-author"
toggle-class-name="js-user-search js-author-search"
toggle-label="Author"
field-name="author_id"
:project-id="projectId"></user-filter>
<user-filter
dropdown-class-name="dropdown-menu-author"
toggle-class-name="js-assignee-search"
toggle-label="Assignee"
field-name="assignee_id"
:null-user="true"
:project-id="projectId"></user-filter>
<milestone-filter :milestone-path="milestonePath"></milestone-filter>
<label-filter :label-path="labelPath"></label-filter>
</div>
`,
});
/* eslint-disable no-new */
/* global Vue */
/* global LabelsSelect */
module.exports = Vue.extend({
name: 'filter-label',
props: {
labelPath: {
type: String,
required: true,
},
},
mounted() {
new LabelsSelect(this.$refs.dropdown);
},
template: `
<div class="dropdown">
<button
class="dropdown-menu-toggle js-label-select js-multiselect js-extra-options"
type="button"
data-toggle="dropdown"
data-show-any="true"
data-show-no="true"
:data-labels="labelPath"
ref="dropdown">
<span class="dropdown-toggle-text">
Label
</span>
<i class="fa fa-chevron-down"></i>
</button>
<div class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable">
<div class="dropdown-title">
Filter by label
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button">
<i class="fa fa-times dropdown-menu-close-icon"></i>
</button>
</div>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
placeholder="Search"
autocomplete="off" />
<i class="fa fa-search dropdown-input-search"></i>
<i role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div>
</div>
</div>
`,
});
/* eslint-disable no-new */
/* global Vue */
/* global MilestoneSelect */
module.exports = Vue.extend({
name: 'filter-milestone',
props: {
milestonePath: {
type: String,
required: true,
},
},
mounted() {
new MilestoneSelect(null, this.$refs.dropdown);
},
template: `
<div class="dropdown">
<button
class="dropdown-menu-toggle js-milestone-select"
type="button"
data-toggle="dropdown"
data-show-any="true"
data-show-upcoming="true"
data-field-name="milestone_title"
:data-milestones="milestonePath"
ref="dropdown">
<span class="dropdown-toggle-text">
Milestone
</span>
<i class="fa fa-chevron-down"></i>
</button>
<div class="dropdown-menu dropdown-select dropdown-menu-selectable dropdown-menu-milestone">
<div class="dropdown-title">
<span>Filter by milestone</span>
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button">
<i class="fa fa-times dropdown-menu-close-icon"></i>
</button>
</div>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
placeholder="Search milestones"
autocomplete="off" />
<i class="fa fa-search dropdown-input-search"></i>
<i role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div>
</div>
</div>
`,
});
/* eslint-disable no-new */
/* global Vue */
/* global UsersSelect */
module.exports = Vue.extend({
name: 'filter-user',
props: {
toggleClassName: {
type: String,
required: true,
},
dropdownClassName: {
type: String,
required: false,
default: '',
},
toggleLabel: {
type: String,
required: true,
},
fieldName: {
type: String,
required: true,
},
nullUser: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: Number,
required: true,
},
},
mounted() {
new UsersSelect(null, this.$refs.dropdown);
},
computed: {
currentUsername() {
return gon.current_username;
},
dropdownTitle() {
return `Filter by ${this.toggleLabel.toLowerCase()}`;
},
inputPlaceholder() {
return `Search ${this.toggleLabel.toLowerCase()}`;
},
},
template: `
<div class="dropdown">
<button
class="dropdown-menu-toggle js-user-search"
:class="toggleClassName"
type="button"
data-toggle="dropdown"
data-current-user="true"
:data-any-user="'Any ' + toggleLabel"
:data-null-user="nullUser"
:data-field-name="fieldName"
:data-project-id="projectId"
:data-first-user="currentUsername"
ref="dropdown">
<span class="dropdown-toggle-text">
{{ toggleLabel }}
</span>
<i class="fa fa-chevron-down"></i>
</button>
<div
class="dropdown-menu dropdown-select dropdown-menu-user dropdown-menu-selectable"
:class="dropdownClassName">
<div class="dropdown-title">
{{ dropdownTitle }}
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button">
<i class="fa fa-times dropdown-menu-close-icon"></i>
</button>
</div>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
autocomplete="off"
:placeholder="inputPlaceholder" />
<i class="fa fa-search dropdown-input-search"></i>
<i
role="button"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear">
</i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div>
</div>
</div>
`,
});
/* global Vue */
require('./tabs');
const modalFilters = require('./filters');
(() => {
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.ModalHeader = Vue.extend({
mixins: [gl.issueBoards.ModalMixins],
props: {
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
},
......@@ -31,6 +45,7 @@ require('./tabs');
},
components: {
'modal-tabs': gl.issueBoards.ModalTabs,
modalFilters,
},
template: `
<div>
......@@ -51,6 +66,11 @@ require('./tabs');
<div
class="add-issues-search append-bottom-10"
v-if="showSearch">
<modal-filters
:project-id="projectId"
:milestone-path="milestonePath"
:label-path="labelPath">
</modal-filters>
<input
placeholder="Search issues..."
class="form-control"
......
......@@ -27,6 +27,18 @@ require('./empty_state');
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
......@@ -52,17 +64,27 @@ require('./empty_state');
this.issuesCount = false;
}
},
filter: {
handler() {
this.loadIssues(true);
},
deep: true,
},
},
methods: {
searchOperation: _.debounce(function searchOperationDebounce() {
this.loadIssues(true);
}, 500),
loadIssues(clearIssues = false) {
return gl.boardService.getBacklog({
if (!this.showAddIssuesModal) return false;
const queryData = Object.assign({}, this.filter, {
search: this.searchTerm,
page: this.page,
per: this.perPage,
}).then((res) => {
});
return gl.boardService.getBacklog(queryData).then((res) => {
const data = res.json();
if (clearIssues) {
......@@ -112,8 +134,13 @@ require('./empty_state');
class="add-issues-modal"
v-if="showAddIssuesModal">
<div class="add-issues-container">
<modal-header></modal-header>
<modal-header
:project-id="projectId"
:milestone-path="milestonePath"
:label-path="labelPath">
</modal-header>
<modal-list
:image="blankStateImage"
:issue-link-base="issueLinkBase"
:root-path="rootPath"
v-if="!loading && showList"></modal-list>
......
......@@ -14,6 +14,10 @@
type: String,
required: true,
},
image: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
......@@ -110,6 +114,19 @@
<section
class="add-issues-list add-issues-list-columns"
ref="list">
<div
class="empty-state add-issues-empty-state-filter text-center"
v-if="issuesCount > 0 && issues.length === 0">
<div
class="svg-content"
v-html="image">
</div>
<div class="text-content">
<h4>
There are no issues to show.
</h4>
</div>
</div>
<div
v-for="group in groupedIssues"
class="add-issues-list-column">
......
......@@ -18,6 +18,17 @@
page: 1,
perPage: 50,
};
this.setDefaultFilter();
}
setDefaultFilter() {
this.store.filter = {
author_id: '',
assignee_id: '',
milestone_title: '',
label_name: [],
};
}
selectedCount() {
......
/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars */
/* global Vue */
Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
next(function (response) {
Vue.activeResources -= 1;
});
});
/* eslint-disable no-new, no-param-reassign */
/* global Vue, CommitsPipelineStore, PipelinesService, Flash */
window.Vue = require('vue');
require('./pipelines_table');
/**
* Commits View > Pipelines Tab > Pipelines Table.
* Merge Request View > Pipelines Tab > Pipelines Table.
*
* Renders Pipelines table in pipelines tab in the commits show view.
* Renders Pipelines table in pipelines tab in the merge request show view.
*/
$(() => {
window.gl = window.gl || {};
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
if (gl.commits.PipelinesTableBundle) {
gl.commits.PipelinesTableBundle.$destroy(true);
}
gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView({
el: document.querySelector('#commit-pipeline-table-view'),
});
});
/* globals Vue */
/* eslint-disable no-unused-vars, no-param-reassign */
/**
* Pipelines service.
*
* Used to fetch the data used to render the pipelines table.
* Uses Vue.Resource
*/
class PipelinesService {
constructor(endpoint) {
this.pipelines = Vue.resource(endpoint);
}
/**
* Given the root param provided when the class is initialized, will
* make a GET request.
*
* @return {Promise}
*/
all() {
return this.pipelines.get();
}
}
window.gl = window.gl || {};
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
gl.commits.pipelines.PipelinesService = PipelinesService;
/* eslint-disable no-underscore-dangle*/
/**
* Pipelines' Store for commits view.
*
* Used to store the Pipelines rendered in the commit view in the pipelines table.
*/
class PipelinesStore {
constructor() {
this.state = {};
this.state.pipelines = [];
}
storePipelines(pipelines = []) {
this.state.pipelines = pipelines;
return pipelines;
}
/**
* Once the data is received we will start the time ago loops.
*
* Everytime a request is made like retry or cancel a pipeline, every 10 seconds we
* update the time to show how long as passed.
*
*/
startTimeAgoLoops() {
const startTimeLoops = () => {
this.timeLoopInterval = setInterval(function timeloopInterval() {
this.$children[0].$children.reduce((acc, component) => {
const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0];
acc.push(timeAgoComponent);
return acc;
}, []).forEach(e => e.changeTime());
}, 10000);
};
startTimeLoops();
const removeIntervals = () => clearInterval(this.timeLoopInterval);
const startIntervals = () => startTimeLoops();
gl.VueRealtimeListener(removeIntervals, startIntervals);
}
}
window.gl = window.gl || {};
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
gl.commits.pipelines.PipelinesStore = PipelinesStore;
/* eslint-disable no-new, no-param-reassign */
/* global Vue, CommitsPipelineStore, PipelinesService, Flash */
window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
require('../../lib/utils/common_utils');
require('../../vue_shared/vue_resource_interceptor');
require('../../vue_shared/components/pipelines_table');
require('../../vue_realtime_listener/index');
require('./pipelines_service');
require('./pipelines_store');
/**
*
* Uses `pipelines-table-component` to render Pipelines table with an API call.
* Endpoint is provided in HTML and passed as `endpoint`.
* We need a store to store the received environemnts.
* We need a service to communicate with the server.
*
* Necessary SVG in the table are provided as props. This should be refactored
* as soon as we have Webpack and can load them directly into JS files.
*/
(() => {
window.gl = window.gl || {};
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', {
components: {
'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
},
/**
* Accesses the DOM to provide the needed data.
* Returns the necessary props to render `pipelines-table-component` component.
*
* @return {Object}
*/
data() {
const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
const svgsData = document.querySelector('.pipeline-svgs').dataset;
const store = new gl.commits.pipelines.PipelinesStore();
// Transform svgs DOMStringMap to a plain Object.
const svgsObject = gl.utils.DOMStringMapToObject(svgsData);
return {
endpoint: pipelinesTableData.endpoint,
svgs: svgsObject,
store,
state: store.state,
isLoading: false,
};
},
/**
* When the component is created the service to fetch the data will be
* initialized with the correct endpoint.
*
* A request to fetch the pipelines will be made.
* In case of a successfull response we will store the data in the provided
* store, in case of a failed response we need to warn the user.
*
*/
created() {
const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint);
this.isLoading = true;
return pipelinesService.all()
.then(response => response.json())
.then((json) => {
this.store.storePipelines(json);
this.store.startTimeAgoLoops.call(this, Vue);
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert');
});
},
template: `
<div>
<div class="pipelines realtime-loading" v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i>
</div>
<div class="blank-state blank-state-no-icon"
v-if="!isLoading && state.pipelines.length === 0">
<h2 class="blank-state-title js-blank-state-title">
No pipelines to show
</h2>
</div>
<div class="table-holder pipelines"
v-if="!isLoading && state.pipelines.length > 0">
<pipelines-table-component
:pipelines="state.pipelines"
:svgs="svgs">
</pipelines-table-component>
</div>
</div>
`,
});
})();
......@@ -166,7 +166,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:commit:pipelines':
new gl.MiniPipelineGraph({
container: '.js-pipeline-table',
});
}).bindEvents();
break;
case 'projects:commits:show':
case 'projects:activity':
......@@ -275,7 +275,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
new gl.ProtectedBranchCreate();
new gl.ProtectedBranchEditList();
break;
case 'projects:variables:index':
case 'projects:ci_cd:show':
new gl.ProjectVariables();
break;
case 'ci:lints:create':
......
......@@ -78,8 +78,8 @@ require('../window')(function(w){
},
destroy: function() {
if (this.listTemplate) {
var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
if (this.listTemplate && dynamicList) {
dynamicList.outerHTML = this.listTemplate;
}
}
......
......@@ -4,7 +4,7 @@
window.Vue = require('vue');
window.timeago = require('vendor/timeago');
require('../../lib/utils/text_utility');
require('../../vue_common_component/commit');
require('../../vue_shared/components/commit');
require('./environment_actions');
require('./environment_external_url');
require('./environment_stop');
......@@ -147,12 +147,12 @@ require('./environment_terminal_button');
},
/**
* Returns the value of the `stoppable?` key provided in the response.
* Returns the value of the `stop_action?` key provided in the response.
*
* @returns {Boolean}
*/
isStoppable() {
return this.model['stoppable?'];
hasStopAction() {
return this.model['stop_action?'];
},
/**
......@@ -508,7 +508,7 @@ require('./environment_terminal_button');
</external-url-component>
</div>
<div v-if="isStoppable && canCreateDeployment"
<div v-if="hasStopAction && canCreateDeployment"
class="inline js-stop-component-container">
<stop-component
:stop-url="model.stop_path">
......
window.Vue = require('vue');
require('./stores/environments_store');
require('./components/environment');
require('./vue_resource_interceptor');
require('../vue_shared/vue_resource_interceptor');
$(() => {
window.gl = window.gl || {};
......
......@@ -8,7 +8,7 @@ require('./filtered_search_dropdown');
super(droplab, dropdown, input, filter);
this.config = {
droplabAjaxFilter: {
endpoint: '/autocomplete/users.json',
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
per_page: 20,
......
......@@ -4,10 +4,17 @@
(function() {
this.LabelsSelect = (function() {
function LabelsSelect() {
var _this;
function LabelsSelect(els) {
var _this, $els;
_this = this;
$('.js-label-select').each(function(i, dropdown) {
$els = $(els);
if (!els) {
$els = $('.js-label-select');
}
$els.each(function(i, dropdown) {
var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer;
$dropdown = $(dropdown);
$dropdownContainer = $dropdown.closest('.labels-filter');
......@@ -324,7 +331,7 @@
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(label, $el, e, isMarking) {
var isIssueIndex, isMRIndex, page;
var isIssueIndex, isMRIndex, page, boardsModel;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
......@@ -346,22 +353,31 @@
return;
}
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length) {
boardsModel = gl.issueBoards.BoardsStore.state.filters;
} else if ($dropdown.closest('.add-issues-modal').length) {
boardsModel = gl.issueBoards.ModalStore.store.filter;
}
if (boardsModel) {
if (label.isAny) {
gl.issueBoards.BoardsStore.state.filters['label_name'] = [];
boardsModel['label_name'] = [];
}
else if ($el.hasClass('is-active')) {
gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title);
boardsModel['label_name'].push(label.title);
}
else {
var filters = gl.issueBoards.BoardsStore.state.filters['label_name'];
var filters = boardsModel['label_name'];
filters = filters.filter(function (filteredLabel) {
return filteredLabel !== label.title;
});
gl.issueBoards.BoardsStore.state.filters['label_name'] = filters;
boardsModel['label_name'] = filters;
}
gl.issueBoards.BoardsStore.updateFiltersUrl();
if (!$dropdown.closest('.add-issues-modal').length) {
gl.issueBoards.BoardsStore.updateFiltersUrl();
}
e.preventDefault();
return;
}
......
......@@ -7,19 +7,28 @@ ace_modes = Dir[ace_gem_path + '/vendor/assets/javascripts/ace/mode-*.js'].sort.
File.basename(file, '.js').sub(/^mode-/, '')
end
%>
// Lazy-load configuration when ace.edit is called
(function() {
window.gon = window.gon || {};
var basePath = (window.gon.relative_url_root || '').replace(/\/$/, '') + '/assets/ace';
ace.config.set('basePath', basePath);
var basePath;
var ace = window.ace;
var edit = ace.edit;
ace.edit = function() {
window.gon = window.gon || {};
basePath = (window.gon.relative_url_root || '').replace(/\/$/, '') + '/assets/ace';
ace.config.set('basePath', basePath);
// configure paths for all worker modules
// configure paths for all worker modules
<% ace_workers.each do |worker| %>
ace.config.setModuleUrl('ace/mode/<%= worker %>_worker', basePath + '/worker-<%= worker %>.js');
ace.config.setModuleUrl('ace/mode/<%= worker %>_worker', basePath + '/<%= File.basename(asset_path("ace/worker-#{worker}.js")) %>');
<% end %>
// configure paths for all mode modules
// configure paths for all mode modules
<% ace_modes.each do |mode| %>
ace.config.setModuleUrl('ace/mode/<%= mode %>', basePath + '/mode-<%= mode %>.js');
ace.config.setModuleUrl('ace/mode/<%= mode %>', basePath + '/<%= File.basename(asset_path("ace/mode-#{mode}.js")) %>');
<% end %>
// restore original method
ace.edit = edit;
return ace.edit.apply(ace, arguments);
};
})();
......@@ -69,6 +69,9 @@
var hash = w.gl.utils.getLocationHash();
if (!hash) return;
// This is required to handle non-unicode characters in hash
hash = decodeURIComponent(hash);
var navbar = document.querySelector('.navbar-gitlab');
var subnav = document.querySelector('.layout-nav');
var fixedTabs = document.querySelector('.js-tabs-affix');
......@@ -134,6 +137,14 @@
return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
};
gl.utils.isMetaClick = function(e) {
// Identify following special clicks
// 1) Cmd + Click on Mac (e.metaKey)
// 2) Ctrl + Click on PC (e.ctrlKey)
// 3) Middle-click or Mouse Wheel Click (e.which is 2)
return e.metaKey || e.ctrlKey || e.which === 2;
};
gl.utils.scrollToElement = function($el) {
var top = $el.offset().top;
gl.navBarHeight = gl.navBarHeight || $('.navbar-gitlab').height();
......@@ -230,5 +241,16 @@
return upperCaseHeaders;
};
/**
* Transforms a DOMStringMap into a plain object.
*
* @param {DOMStringMap} DOMStringMapObject
* @returns {Object}
*/
w.gl.utils.DOMStringMapToObject = DOMStringMapObject => Object.keys(DOMStringMapObject).reduce((acc, element) => {
acc[element] = DOMStringMapObject[element];
return acc;
}, {});
})(window);
}).call(this);
......@@ -8,6 +8,8 @@ window.dateFormat = require('vendor/date.format');
(function() {
(function(w) {
var base;
var timeagoInstance;
if (w.gl == null) {
w.gl = {};
}
......@@ -24,49 +26,51 @@ window.dateFormat = require('vendor/date.format');
return this.days[date.getDay()];
};
w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) {
if (setTimeago == null) {
setTimeago = true;
}
$timeagoEls.filter(':not([data-timeago-rendered])').each(function() {
var $el = $(this);
$el.attr('title', gl.utils.formatDate($el.attr('datetime')));
w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago = true) {
$timeagoEls.each((i, el) => {
el.setAttribute('title', gl.utils.formatDate(el.getAttribute('datetime')));
if (setTimeago) {
// Recreate with custom template
$el.tooltip({
$(el).tooltip({
template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>'
});
}
$el.attr('data-timeago-rendered', true);
gl.utils.renderTimeago($el);
el.classList.add('js-timeago-render');
});
gl.utils.renderTimeago($timeagoEls);
};
w.gl.utils.getTimeago = function() {
var locale = function(number, index) {
return [
['less than a minute ago', 'a while'],
['less than a minute ago', 'in %s seconds'],
['about a minute ago', 'in 1 minute'],
['%s minutes ago', 'in %s minutes'],
['about an hour ago', 'in 1 hour'],
['about %s hours ago', 'in %s hours'],
['a day ago', 'in 1 day'],
['%s days ago', 'in %s days'],
['a week ago', 'in 1 week'],
['%s weeks ago', 'in %s weeks'],
['a month ago', 'in 1 month'],
['%s months ago', 'in %s months'],
['a year ago', 'in 1 year'],
['%s years ago', 'in %s years']
][index];
};
timeago.register('gl_en', locale);
return timeago();
var locale;
if (!timeagoInstance) {
locale = function(number, index) {
return [
['less than a minute ago', 'a while'],
['less than a minute ago', 'in %s seconds'],
['about a minute ago', 'in 1 minute'],
['%s minutes ago', 'in %s minutes'],
['about an hour ago', 'in 1 hour'],
['about %s hours ago', 'in %s hours'],
['a day ago', 'in 1 day'],
['%s days ago', 'in %s days'],
['a week ago', 'in 1 week'],
['%s weeks ago', 'in %s weeks'],
['a month ago', 'in 1 month'],
['%s months ago', 'in %s months'],
['a year ago', 'in 1 year'],
['%s years ago', 'in %s years']
][index];
};
timeago.register('gl_en', locale);
timeagoInstance = timeago();
}
return timeagoInstance;
};
w.gl.utils.timeFor = function(time, suffix, expiredLabel) {
......@@ -85,9 +89,30 @@ window.dateFormat = require('vendor/date.format');
return timefor;
};
w.gl.utils.renderTimeago = function($element) {
var timeagoInstance = gl.utils.getTimeago();
timeagoInstance.render($element, 'gl_en');
w.gl.utils.cachedTimeagoElements = [];
w.gl.utils.renderTimeago = function($els) {
if (!$els && !w.gl.utils.cachedTimeagoElements.length) {
w.gl.utils.cachedTimeagoElements = [].slice.call(document.querySelectorAll('.js-timeago-render'));
} else if ($els) {
w.gl.utils.cachedTimeagoElements = w.gl.utils.cachedTimeagoElements.concat($els.toArray());
}
w.gl.utils.cachedTimeagoElements.forEach(gl.utils.updateTimeagoText);
};
w.gl.utils.updateTimeagoText = function(el) {
const timeago = gl.utils.getTimeago();
const formattedDate = timeago.format(el.getAttribute('datetime'), 'gl_en');
if (el.textContent !== formattedDate) {
el.textContent = formattedDate;
}
};
w.gl.utils.initTimeagoTimeout = function() {
gl.utils.renderTimeago();
gl.utils.timeagoTimeout = setTimeout(gl.utils.initTimeagoTimeout, 1000);
};
w.gl.utils.getDayDifference = function(a, b) {
......
......@@ -61,7 +61,6 @@ require('./flash');
constructor({ action, setUrl, stubLocation } = {}) {
this.diffsLoaded = false;
this.pipelinesLoaded = false;
this.commitsLoaded = false;
this.fixedLayoutPref = null;
......@@ -83,12 +82,18 @@ require('./flash');
$(document)
.on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
.on('click', '.js-show-tab', this.showTab);
$('.merge-request-tabs a[data-toggle="tab"]')
.on('click', this.clickTab);
}
unbindEvents() {
$(document)
.off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
.off('click', '.js-show-tab', this.showTab);
$('.merge-request-tabs a[data-toggle="tab"]')
.off('click', this.clickTab);
}
showTab(e) {
......@@ -96,6 +101,14 @@ require('./flash');
this.activateTab($(e.target).data('action'));
}
clickTab(e) {
if (e.target && gl.utils.isMetaClick(e)) {
const targetLink = e.target.getAttribute('href');
e.stopImmediatePropagation();
window.open(targetLink, '_blank');
}
}
tabShown(e) {
const $target = $(e.target);
const action = $target.data('action');
......@@ -116,10 +129,6 @@ require('./flash');
$.scrollTo('.merge-request-details .merge-request-tabs', {
offset: -navBarHeight,
});
} else if (action === 'pipelines') {
this.loadPipelines($target.attr('href'));
this.expandView();
this.resetViewContainer();
} else {
this.expandView();
this.resetViewContainer();
......@@ -244,25 +253,6 @@ require('./flash');
});
}
loadPipelines(source) {
if (this.pipelinesLoaded) {
return;
}
this.ajaxGet({
url: `${source}.json`,
success: (data) => {
$('#pipelines').html(data.html);
gl.utils.localTimeAgo($('.js-timeago', '#pipelines'));
this.pipelinesLoaded = true;
this.scrollToElement('#pipelines');
new gl.MiniPipelineGraph({
container: '.js-pipeline-table',
});
},
});
}
// Show or hide the loading spinner
//
// status - Boolean, true to show, false to hide
......
......@@ -50,6 +50,8 @@ require('./smart_interval');
this.getCIStatus(false);
this.retrieveSuccessIcon();
this.initMiniPipelineGraph();
this.ciStatusInterval = new global.SmartInterval({
callback: this.getCIStatus.bind(this, true),
startingInterval: 10000,
......@@ -65,6 +67,7 @@ require('./smart_interval');
incrementByFactorOf: 15000,
immediateExecution: true,
});
notifyPermissions();
}
......@@ -253,17 +256,20 @@ require('./smart_interval');
case "failed":
case "canceled":
case "not_found":
return this.setMergeButtonClass('btn-danger');
this.setMergeButtonClass('btn-danger');
break;
case "running":
return this.setMergeButtonClass('btn-info');
this.setMergeButtonClass('btn-info');
break;
case "success":
case "success_with_warnings":
return this.setMergeButtonClass('btn-create');
this.setMergeButtonClass('btn-create');
}
} else {
$('.ci_widget.ci-error').show();
return this.setMergeButtonClass('btn-danger');
this.setMergeButtonClass('btn-danger');
}
this.initMiniPipelineGraph();
};
MergeRequestWidget.prototype.showCICoverage = function(coverage) {
......@@ -286,6 +292,12 @@ require('./smart_interval');
$('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/'));
};
MergeRequestWidget.prototype.initMiniPipelineGraph = function() {
new gl.MiniPipelineGraph({
container: '.js-pipeline-inline-mr-widget-graph:visible',
}).bindEvents();
};
return MergeRequestWidget;
})();
})(window.gl || (window.gl = {}));
......@@ -5,13 +5,20 @@
(function() {
this.MilestoneSelect = (function() {
function MilestoneSelect(currentProject) {
var _this;
function MilestoneSelect(currentProject, els) {
var _this, $els;
if (currentProject != null) {
_this = this;
this.currentProject = JSON.parse(currentProject);
}
$('.js-milestone-select').each(function(i, dropdown) {
$els = $(els);
if (!els) {
$els = $('.js-milestone-select');
}
$els.each(function(i, dropdown) {
var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, useId, showMenuAbove;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
......@@ -108,7 +115,7 @@
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page;
var data, isIssueIndex, isMRIndex, page, boardsStore;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
......@@ -116,9 +123,19 @@
e.preventDefault();
return;
}
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) {
gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name;
gl.issueBoards.BoardsStore.updateFiltersUrl();
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length) {
boardsStore = gl.issueBoards.BoardsStore.state.filters;
} else if ($dropdown.closest('.add-issues-modal').length) {
boardsStore = gl.issueBoards.ModalStore.store.filter;
}
if (boardsStore) {
boardsStore[$dropdown.data('field-name')] = selected.name;
if (!$dropdown.closest('.add-issues-modal').length) {
gl.issueBoards.BoardsStore.updateFiltersUrl();
}
e.preventDefault();
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (selected.name != null) {
......
......@@ -21,8 +21,6 @@
this.container = opts.container || '';
this.dropdownListSelector = '.js-builds-dropdown-container';
this.getBuildsList = this.getBuildsList.bind(this);
this.bindEvents();
}
/**
......
/* eslint-disable arrow-parens, class-methods-use-this, no-param-reassign */
/* global Cookies */
((global) => {
let singleton;
(() => {
const pinnedStateCookie = 'pin_nav';
const sidebarBreakpoint = 1024;
......@@ -23,11 +21,12 @@
class Sidebar {
constructor() {
if (!singleton) {
singleton = this;
singleton.init();
if (!Sidebar.singleton) {
Sidebar.singleton = this;
Sidebar.singleton.init();
}
return singleton;
return Sidebar.singleton;
}
init() {
......@@ -39,7 +38,7 @@
$(document)
.on('click', sidebarToggleSelector, () => this.toggleSidebar())
.on('click', pinnedToggleSelector, () => this.togglePinnedState())
.on('click', 'html, body', (e) => this.handleClickEvent(e))
.on('click', 'html, body, a, button', (e) => this.handleClickEvent(e))
.on('DOMContentLoaded', () => this.renderState())
.on('todo:toggle', (e, count) => this.updateTodoCount(count));
this.renderState();
......@@ -88,10 +87,12 @@
$pinnedToggle.attr('title', tooltipText).tooltip('fixTitle').tooltip(tooltipState);
if (this.isExpanded) {
setTimeout(() => $(sidebarContentSelector).niceScroll().updateScrollBar(), 200);
const sidebarContent = $(sidebarContentSelector);
setTimeout(() => { sidebarContent.niceScroll().updateScrollBar(); }, 200);
}
}
}
global.Sidebar = Sidebar;
})(window.gl || (window.gl = {}));
window.gl = window.gl || {};
gl.Sidebar = Sidebar;
})();
......@@ -146,14 +146,26 @@
}
goToTodoUrl(e) {
const todoLink = $(this).data('url');
const todoLink = this.dataset.url;
let targetLink = e.target.getAttribute('href');
if (e.target.tagName === 'IMG') { // See if clicked target was Avatar
targetLink = e.target.parentElement.getAttribute('href'); // Parent of Avatar is link
}
if (!todoLink) {
return;
}
// Allow Meta-Click or Mouse3-click to open in a new tab
if (e.metaKey || e.which === 2) {
if (gl.utils.isMetaClick(e)) {
e.preventDefault();
return window.open(todoLink, '_blank');
// Meta-Click on username leads to different URL than todoLink.
// Turbolinks can resolve that URL, but window.open requires URL manually.
if (targetLink !== todoLink) {
return window.open(targetLink, '_blank');
} else {
return window.open(todoLink, '_blank');
}
} else {
return gl.utils.visitUrl(todoLink);
}
......
......@@ -8,7 +8,8 @@
slice = [].slice;
this.UsersSelect = (function() {
function UsersSelect(currentUser) {
function UsersSelect(currentUser, els) {
var $els;
this.users = bind(this.users, this);
this.user = bind(this.user, this);
this.usersPath = "/autocomplete/users.json";
......@@ -20,7 +21,14 @@
this.currentUser = JSON.parse(currentUser);
}
}
$('.js-user-search').each((function(_this) {
$els = $(els);
if (!els) {
$els = $('.js-user-search');
}
$els.each((function(_this) {
return function(i, dropdown) {
var options = {};
var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove;
......@@ -193,7 +201,9 @@
selectedId = user.id;
return;
}
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) {
if ($el.closest('.add-issues-modal').length) {
gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
} else if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) {
selectedId = user.id;
gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id;
gl.issueBoards.BoardsStore.updateFiltersUrl();
......
/* eslint-disable no-param-reassign */
/* global Vue, VueResource, gl */
window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
require('../vue_common_component/commit');
require('../vue_pagination/index');
require('../boards/vue_resource_interceptor');
require('./status');
require('./store');
require('./pipeline_url');
require('./stage');
require('./stages');
require('./pipeline_actions');
require('./time_ago');
require('../lib/utils/common_utils');
require('../vue_shared/vue_resource_interceptor');
require('./pipelines');
(() => {
const project = document.querySelector('.pipelines');
const entry = document.querySelector('.vue-pipelines-index');
const svgs = document.querySelector('.pipeline-svgs');
$(() => new Vue({
el: document.querySelector('.vue-pipelines-index'),
if (!entry) return null;
return new Vue({
el: entry,
data: {
data() {
const project = document.querySelector('.pipelines');
const svgs = document.querySelector('.pipeline-svgs').dataset;
// Transform svgs DOMStringMap to a plain Object.
const svgsObject = gl.utils.DOMStringMapToObject(svgs);
return {
scope: project.dataset.url,
store: new gl.PipelineStore(),
svgs: svgs.dataset,
},
components: {
'vue-pipelines': gl.VuePipelines,
},
template: `
<vue-pipelines
:scope='scope'
:store='store'
:svgs='svgs'
>
</vue-pipelines>
`,
});
})();
svgs: svgsObject,
};
},
components: {
'vue-pipelines': gl.VuePipelines,
},
template: `
<vue-pipelines
:scope='scope'
:store='store'
:svgs='svgs'
>
</vue-pipelines>
`,
}));
......@@ -50,9 +50,9 @@
<button
v-if='artifacts'
class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download"
data-toggle="dropdown"
title="Artifacts"
data-placement="top"
data-toggle="dropdown"
aria-label="Artifacts"
>
<i class="fa fa-download" aria-hidden="true"></i>
......@@ -81,8 +81,7 @@
data-placement="top"
data-toggle="dropdown"
:href='pipeline.retry_path'
aria-label="Retry"
>
aria-label="Retry">
<i class="fa fa-repeat" aria-hidden="true"></i>
</a>
<a
......@@ -94,8 +93,7 @@
data-placement="top"
data-toggle="dropdown"
:href='pipeline.cancel_path'
aria-label="Cancel"
>
aria-label="Cancel">
<i class="fa fa-remove" aria-hidden="true"></i>
</a>
</div>
......
/* global Vue, gl */
/* eslint-disable no-param-reassign */
window.Vue = require('vue');
require('../vue_shared/components/table_pagination');
require('./store');
require('../vue_shared/components/pipelines_table');
((gl) => {
gl.VuePipelines = Vue.extend({
components: {
runningPipeline: gl.VueRunningPipeline,
pipelineActions: gl.VuePipelineActions,
stages: gl.VueStages,
commit: gl.CommitComponent,
pipelineUrl: gl.VuePipelineUrl,
pipelineHead: gl.VuePipelineHead,
glPagination: gl.VueGlPagination,
statusScope: gl.VueStatusScope,
timeAgo: gl.VueTimeAgo,
'gl-pagination': gl.VueGlPagination,
'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
},
data() {
return {
pipelines: [],
......@@ -38,87 +38,29 @@
change(pagenum, apiScope) {
gl.utils.visitUrl(`?scope=${apiScope}&p=${pagenum}`);
},
author(pipeline) {
if (!pipeline.commit) return { avatar_url: '', web_url: '', username: '' };
if (pipeline.commit.author) return pipeline.commit.author;
return {
avatar_url: pipeline.commit.author_gravatar_url,
web_url: `mailto:${pipeline.commit.author_email}`,
username: pipeline.commit.author_name,
};
},
ref(pipeline) {
const { ref } = pipeline;
return { name: ref.name, tag: ref.tag, ref_url: ref.path };
},
commitTitle(pipeline) {
return pipeline.commit ? pipeline.commit.title : '';
},
commitSha(pipeline) {
return pipeline.commit ? pipeline.commit.short_id : '';
},
commitUrl(pipeline) {
return pipeline.commit ? pipeline.commit.commit_path : '';
},
match(string) {
return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase());
},
},
template: `
<div>
<div class="pipelines realtime-loading" v-if='pipelines.length < 1'>
<div class="pipelines realtime-loading" v-if='pageRequest'>
<i class="fa fa-spinner fa-spin"></i>
</div>
<div class="table-holder" v-if='pipelines.length'>
<table class="table ci-table">
<thead>
<tr>
<th class="pipeline-status">Status</th>
<th class="pipeline-info">Pipeline</th>
<th class="pipeline-commit">Commit</th>
<th class="pipeline-stages">Stages</th>
<th class="pipeline-date"></th>
<th class="pipeline-actions hidden-xs"></th>
</tr>
</thead>
<tbody>
<tr class="commit" v-for='pipeline in pipelines'>
<status-scope
:pipeline='pipeline'
:match='match'
:svgs='svgs'
>
</status-scope>
<pipeline-url :pipeline='pipeline'></pipeline-url>
<td>
<commit
:commit-icon-svg='svgs.commitIconSvg'
:author='author(pipeline)'
:tag="pipeline.ref.tag"
:title='commitTitle(pipeline)'
:commit-ref='ref(pipeline)'
:short-sha='commitSha(pipeline)'
:commit-url='commitUrl(pipeline)'
>
</commit>
</td>
<stages
:pipeline='pipeline'
:svgs='svgs'
:match='match'
>
</stages>
<time-ago :pipeline='pipeline' :svgs='svgs'></time-ago>
<pipeline-actions :pipeline='pipeline' :svgs='svgs'></pipeline-actions>
</tr>
</tbody>
</table>
<div class="blank-state blank-state-no-icon"
v-if="!pageRequest && pipelines.length === 0">
<h2 class="blank-state-title js-blank-state-title">
No pipelines to show
</h2>
</div>
<div class="pipelines realtime-loading" v-if='pageRequest'>
<i class="fa fa-spinner fa-spin"></i>
<div class="table-holder" v-if='!pageRequest && pipelines.length'>
<pipelines-table-component
:pipelines='pipelines'
:svgs='svgs'>
</pipelines-table-component>
</div>
<gl-pagination
v-if='pageInfo.total > pageInfo.perPage'
v-if='!pageRequest && pipelines.length && pageInfo.total > pageInfo.perPage'
:pagenum='pagenum'
:change='change'
:count='count.all'
......
......@@ -15,7 +15,7 @@
required: true,
},
svgs: {
type: DOMStringMap,
type: Object,
required: true,
},
match: {
......
/* global Vue, gl */
/* eslint-disable no-param-reassign */
((gl) => {
gl.VueStages = Vue.extend({
components: {
'vue-stage': gl.VueStage,
},
props: ['pipeline', 'svgs', 'match'],
template: `
<td class="stage-cell">
<div
class="stage-container dropdown js-mini-pipeline-graph"
v-for='stage in pipeline.details.stages'
>
<vue-stage :stage='stage' :svgs='svgs' :match='match'></vue-stage>
</div>
</td>
`,
});
})(window.gl || (window.gl = {}));
......@@ -20,6 +20,7 @@ require('../vue_realtime_listener');
gl.PipelineStore = class {
fetchDataLoop(Vue, pageNum, url, apiScope) {
this.pageRequest = true;
const updatePipelineNums = (count) => {
const { all } = count;
const running = count.running_or_pending;
......@@ -41,16 +42,18 @@ require('../vue_realtime_listener');
this.pageRequest = false;
}, () => {
this.pageRequest = false;
return new Flash('Something went wrong on our end.');
return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
goFetch();
const startTimeLoops = () => {
this.timeLoopInterval = setInterval(() => {
this.$children
.filter(e => e.$options._componentTag === 'time-ago')
.forEach(e => e.changeTime());
this.$children[0].$children.reduce((acc, component) => {
const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0];
acc.push(timeAgoComponent);
return acc;
}, []).forEach(e => e.changeTime());
}, 10000);
};
......
/* global Vue, gl */
/* eslint-disable no-param-reassign */
window.Vue = require('vue');
require('../lib/utils/datetime_utility');
((gl) => {
gl.VueTimeAgo = Vue.extend({
data() {
......
/* eslint-disable no-param-reassign */
/* global Vue */
require('./pipelines_table_row');
/**
* Pipelines Table Component.
*
* Given an array of objects, renders a table.
*/
(() => {
window.gl = window.gl || {};
gl.pipelines = gl.pipelines || {};
gl.pipelines.PipelinesTableComponent = Vue.component('pipelines-table-component', {
props: {
pipelines: {
type: Array,
required: true,
default: () => ([]),
},
/**
* TODO: Remove this when we have webpack.
*/
svgs: {
type: Object,
required: true,
default: () => ({}),
},
},
components: {
'pipelines-table-row-component': gl.pipelines.PipelinesTableRowComponent,
},
template: `
<table class="table ci-table">
<thead>
<tr>
<th class="js-pipeline-status pipeline-status">Status</th>
<th class="js-pipeline-info pipeline-info">Pipeline</th>
<th class="js-pipeline-commit pipeline-commit">Commit</th>
<th class="js-pipeline-stages pipeline-stages">Stages</th>
<th class="js-pipeline-date pipeline-date"></th>
<th class="js-pipeline-actions pipeline-actions hidden-xs"></th>
</tr>
</thead>
<tbody>
<template v-for="model in pipelines"
v-bind:model="model">
<tr is="pipelines-table-row-component"
:pipeline="model"
:svgs="svgs"></tr>
</template>
</tbody>
</table>
`,
});
})();
/* eslint-disable no-param-reassign */
/* global Vue */
require('../../vue_pipelines_index/status');
require('../../vue_pipelines_index/pipeline_url');
require('../../vue_pipelines_index/stage');
require('../../vue_pipelines_index/pipeline_actions');
require('../../vue_pipelines_index/time_ago');
require('./commit');
/**
* Pipeline table row.
*
* Given the received object renders a table row in the pipelines' table.
*/
(() => {
window.gl = window.gl || {};
gl.pipelines = gl.pipelines || {};
gl.pipelines.PipelinesTableRowComponent = Vue.component('pipelines-table-row-component', {
props: {
pipeline: {
type: Object,
required: true,
default: () => ({}),
},
/**
* TODO: Remove this when we have webpack;
*/
svgs: {
type: Object,
required: true,
default: () => ({}),
},
},
components: {
'commit-component': gl.CommitComponent,
'pipeline-actions': gl.VuePipelineActions,
'dropdown-stage': gl.VueStage,
'pipeline-url': gl.VuePipelineUrl,
'status-scope': gl.VueStatusScope,
'time-ago': gl.VueTimeAgo,
},
computed: {
/**
* If provided, returns the commit tag.
* Needed to render the commit component column.
*
* This field needs a lot of verification, because of different possible cases:
*
* 1. person who is an author of a commit might be a GitLab user
* 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
* 3. If GitLab user does not have avatar he/she might have a Gravatar
* 4. If committer is not a GitLab User he/she can have a Gravatar
* 5. We do not have consistent API object in this case
* 6. We should improve API and the code
*
* @returns {Object|Undefined}
*/
commitAuthor() {
let commitAuthorInformation;
// 1. person who is an author of a commit might be a GitLab user
if (this.pipeline &&
this.pipeline.commit &&
this.pipeline.commit.author) {
// 2. if person who is an author of a commit is a GitLab user
// he/she can have a GitLab avatar
if (this.pipeline.commit.author.avatar_url) {
commitAuthorInformation = this.pipeline.commit.author;
// 3. If GitLab user does not have avatar he/she might have a Gravatar
} else if (this.pipeline.commit.author_gravatar_url) {
commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
avatar_url: this.pipeline.commit.author_gravatar_url,
});
}
}
// 4. If committer is not a GitLab User he/she can have a Gravatar
if (this.pipeline &&
this.pipeline.commit) {
commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
web_url: `mailto:${this.pipeline.commit.author_email}`,
username: this.pipeline.commit.author_name,
};
}
return commitAuthorInformation;
},
/**
* If provided, returns the commit tag.
* Needed to render the commit component column.
*
* @returns {String|Undefined}
*/
commitTag() {
if (this.pipeline.ref &&
this.pipeline.ref.tag) {
return this.pipeline.ref.tag;
}
return undefined;
},
/**
* If provided, returns the commit ref.
* Needed to render the commit component column.
*
* Matched `url` prop sent in the API to `path` prop needed
* in the commit component.
*
* @returns {Object|Undefined}
*/
commitRef() {
if (this.pipeline.ref) {
return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
if (prop === 'url') {
accumulator.path = this.pipeline.ref[prop];
} else {
accumulator[prop] = this.pipeline.ref[prop];
}
return accumulator;
}, {});
}
return undefined;
},
/**
* If provided, returns the commit url.
* Needed to render the commit component column.
*
* @returns {String|Undefined}
*/
commitUrl() {
if (this.pipeline.commit &&
this.pipeline.commit.commit_path) {
return this.pipeline.commit.commit_path;
}
return undefined;
},
/**
* If provided, returns the commit short sha.
* Needed to render the commit component column.
*
* @returns {String|Undefined}
*/
commitShortSha() {
if (this.pipeline.commit &&
this.pipeline.commit.short_id) {
return this.pipeline.commit.short_id;
}
return undefined;
},
/**
* If provided, returns the commit title.
* Needed to render the commit component column.
*
* @returns {String|Undefined}
*/
commitTitle() {
if (this.pipeline.commit &&
this.pipeline.commit.title) {
return this.pipeline.commit.title;
}
return undefined;
},
},
methods: {
/**
* FIXME: This should not be in this component but in the components that
* need this function.
*
* Used to render SVGs in the following components:
* - status-scope
* - dropdown-stage
*
* @param {String} string
* @return {String}
*/
match(string) {
return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase());
},
},
template: `
<tr class="commit">
<status-scope
:pipeline="pipeline"
:svgs="svgs"
:match="match">
</status-scope>
<pipeline-url :pipeline="pipeline"></pipeline-url>
<td>
<commit-component
:tag="commitTag"
:commit-ref="commitRef"
:commit-url="commitUrl"
:short-sha="commitShortSha"
:title="commitTitle"
:author="commitAuthor"
:commit-icon-svg="svgs.commitIconSvg">
</commit-component>
</td>
<td class="stage-cell">
<div class="stage-container dropdown js-mini-pipeline-graph"
v-if="pipeline.details.stages.length > 0"
v-for="stage in pipeline.details.stages">
<dropdown-stage
:stage="stage"
:svgs="svgs"
:match="match">
</dropdown-stage>
</div>
</td>
<time-ago :pipeline="pipeline" :svgs="svgs"></time-ago>
<pipeline-actions :pipeline="pipeline" :svgs="svgs"></pipeline-actions>
</tr>
`,
});
})();
/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars,
no-param-reassign, no-plusplus */
/* global Vue */
Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
next((response) => {
if (typeof response.data === 'string') {
response.data = JSON.parse(response.data); // eslint-disable-line
response.data = JSON.parse(response.data);
}
Vue.activeResources--; // eslint-disable-line
Vue.activeResources--;
});
});
Vue.http.interceptors.push((request, next) => {
// needed in order to not break the tests.
if ($.rails) {
request.headers['X-CSRF-Token'] = $.rails.csrfToken();
}
next();
});
......@@ -71,6 +71,27 @@
transition: $unfoldedTransitions;
}
@mixin disableAllAnimation {
/*CSS transitions*/
-o-transition-property: none !important;
-moz-transition-property: none !important;
-ms-transition-property: none !important;
-webkit-transition-property: none !important;
transition-property: none !important;
/*CSS transforms*/
-o-transform: none !important;
-moz-transform: none !important;
-ms-transform: none !important;
-webkit-transform: none !important;
transform: none !important;
/*CSS animations*/
-webkit-animation: none !important;
-moz-animation: none !important;
-o-animation: none !important;
-ms-animation: none !important;
animation: none !important;
}
@function unfoldTransition ($transition) {
// Default values
$property: all;
......@@ -116,11 +137,13 @@ a {
@include transition(background-color, color, border);
}
.tree-table td,
.well-list > li {
@include transition(background-color, border-color);
}
.stage-nav-item {
@include transition(background-color, box-shadow);
}
.nav-sidebar a,
.dropdown-menu a,
.dropdown-menu button,
.dropdown-menu-nav a {
transition: none;
}
......@@ -253,6 +253,8 @@ li.note {
.progress {
margin-bottom: 0;
margin-top: 4px;
box-shadow: none;
background-color: $border-gray-light;
}
}
......
......@@ -313,3 +313,7 @@ ul.controls {
}
}
}
ul.indent-list {
padding: 10px 0 0 30px;
}
......@@ -159,6 +159,7 @@
.cur {
.avatar {
border: 1px solid $white-light;
@include disableAllAnimation;
}
}
}
......@@ -6,8 +6,22 @@
.pagination {
padding: 0;
a {
cursor: pointer;
}
.separator,
.separator:hover {
a {
cursor: default;
background-color: $gray-light;
padding: $gl-vert-padding;
}
}
}
.gap,
.gap:hover {
background-color: $gray-light;
......
......@@ -423,6 +423,13 @@
flex: 1;
margin-top: 0;
&.add-issues-empty-state-filter {
-webkit-flex-direction: column;
flex-direction: column;
-webkit-justify-content: center;
justify-content: center;
}
> .row {
width: 100%;
margin: auto 0;
......@@ -450,6 +457,14 @@
.add-issues-search {
display: -webkit-flex;
display: flex;
.form-control {
margin-left: auto;
@media (min-width: $screen-sm-min) {
max-width: 200px;
}
}
}
.add-issues-list-column {
......@@ -520,3 +535,24 @@
line-height: 15px;
border-radius: 50%;
}
.modal-filters {
display: flex;
> .dropdown {
display: none;
margin-right: 10px;
@media (min-width: $screen-sm-min) {
display: block;
}
}
.dropdown-menu-toggle {
width: 100px;
@media (min-width: $screen-md-min) {
width: 140px;
}
}
}
......@@ -159,7 +159,6 @@
.commit-row-description {
font-size: 14px;
border-left: 1px solid $white-normal;
padding: 10px 15px;
margin: 10px 0;
background: $gray-light;
......
......@@ -193,7 +193,7 @@
top: $header-height;
bottom: 0;
right: 0;
z-index: 10;
z-index: 8;
transition: width .3s;
background: $gray-light;
padding: 10px 20px;
......
......@@ -148,3 +148,7 @@ ul.related-merge-requests > li {
border: 1px solid $border-gray-normal;
}
}
.recaptcha {
margin-bottom: 30px;
}
......@@ -259,3 +259,8 @@
}
}
}
.label-link {
display: inline-block;
vertical-align: text-top;
}
......@@ -80,6 +80,10 @@
.ci_widget {
border-bottom: 1px solid $well-inner-border;
color: $gl-text-color;
display: -webkit-flex;
display: flex;
-webkit-align-items: center;
align-items: center;
svg {
margin-right: 4px;
......@@ -88,12 +92,20 @@
overflow: visible;
}
&> span {
padding-right: 4px;
}
&.ci-success_with_warnings {
i {
color: $gl-warning;
}
}
@media (max-width: $screen-xs-max) {
flex-wrap: wrap;
}
}
.mr-widget-body,
......@@ -102,6 +114,37 @@
padding: $gl-padding;
}
.mr-widget-pipeline-graph {
flex-shrink: 0;
.dropdown-menu {
margin-top: 11px;
}
.ci-action-icon-wrapper {
line-height: 16px;
}
@media (max-width: $screen-xs-max) {
order: 1;
margin-top: $gl-padding-top;
border-radius: 3px;
background-color: $white-light;
border: 1px solid $gray-darker;
width: 100%;
text-align: center;
.dropdown-menu {
margin-left: -97.5px;
}
.arrow-up::before,
.arrow-up::after, {
margin-left: 97.5px;
}
}
}
.normal {
color: $gl-text-color;
}
......
......@@ -183,52 +183,11 @@
}
}
.stage-cell {
font-size: 0;
padding: 10px 4px;
> .stage-container > div > button > span > svg,
> .stage-container > button > svg {
height: 22px;
width: 22px;
position: absolute;
top: -1px;
left: -1px;
z-index: 2;
overflow: visible;
}
.stage-container {
display: inline-block;
position: relative;
height: 22px;
margin: 3px 6px 3px 0;
.tooltip {
white-space: nowrap;
}
.tooltip-inner {
padding: 3px 4px;
}
&:not(:last-child) {
&::after {
content: '';
width: 7px;
position: absolute;
right: -7px;
top: 10px;
border-bottom: 2px solid $border-color;
}
}
}
}
.duration,
.finished-at {
color: $gl-text-color-secondary;
margin: 4px 0;
white-space: nowrap;
.fa {
font-size: 12px;
......@@ -311,6 +270,48 @@
}
}
.stage-cell {
font-size: 0;
padding: 10px 4px;
> .stage-container > div > button > span > svg,
> .stage-container > button > svg {
height: 22px;
width: 22px;
position: absolute;
top: -1px;
left: -1px;
z-index: 2;
overflow: visible;
}
.stage-container {
display: inline-block;
position: relative;
height: 22px;
margin: 3px 6px 3px 0;
.tooltip {
white-space: nowrap;
}
.tooltip-inner {
padding: 3px 4px;
}
&:not(:last-child) {
&::after {
content: '';
width: 7px;
position: absolute;
right: -7px;
top: 10px;
border-bottom: 2px solid $border-color;
}
}
}
}
.admin-builds-table {
.ci-table td:last-child {
min-width: 120px;
......@@ -666,7 +667,7 @@
vertical-align: bottom;
display: inline-block;
position: relative;
font-weight: 200;
font-weight: normal;
}
// Dropdown button in mini pipeline graph
......
......@@ -147,6 +147,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:user_default_external,
:user_oauth_applications,
:version_check_enabled,
:terminal_max_session_time,
disabled_oauth_sign_in_sources: [],
import_sources: [],
......
module SpammableActions
extend ActiveSupport::Concern
include Recaptcha::Verify
included do
before_action :authorize_submit_spammable!, only: :mark_as_spam
end
......@@ -15,6 +17,15 @@ module SpammableActions
private
def recaptcha_params
return {} unless params[:recaptcha_verification] && Gitlab::Recaptcha.load_configurations! && verify_recaptcha
{
recaptcha_verified: true,
spam_log_id: params[:spam_log_id]
}
end
def spammable
raise NotImplementedError, "#{self.class} does not implement #{__method__}"
end
......@@ -22,4 +33,11 @@ module SpammableActions
def authorize_submit_spammable!
access_denied! unless current_user.admin?
end
def render_recaptcha?
return false if spammable.errors.count > 1 # re-render "new" template in case there are other errors
return false unless Gitlab::Recaptcha.enabled?
spammable.spam
end
end
......@@ -37,7 +37,6 @@ class Projects::CommitController < Projects::ApplicationController
format.json do
render json: PipelineSerializer
.new(project: @project, user: @current_user)
.with_pagination(request, response)
.represent(@pipelines)
end
end
......
......@@ -52,10 +52,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
def stop
return render_404 unless @environment.stoppable?
return render_404 unless @environment.available?
new_action = @environment.stop!(current_user)
redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, new_action])
stop_action = @environment.stop_with_action!(current_user)
if stop_action
redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action])
else
redirect_to namespace_project_environment_path(project.namespace, project, @environment)
end
end
def terminal
......
......@@ -98,15 +98,13 @@ class Projects::IssuesController < Projects::ApplicationController
def create
extra_params = { request: request,
merge_request_for_resolving_discussions: merge_request_for_resolving_discussions }
extra_params.merge!(recaptcha_params)
@issue = Issues::CreateService.new(project, current_user, issue_params.merge(extra_params)).execute
respond_to do |format|
format.html do
if @issue.valid?
redirect_to issue_path(@issue)
else
render :new
end
html_response_create
end
format.js do
@link = @issue.attachment.url.to_js
......@@ -183,6 +181,20 @@ class Projects::IssuesController < Projects::ApplicationController
protected
def html_response_create
if @issue.valid?
redirect_to issue_path(@issue)
elsif render_recaptcha?
if params[:recaptcha_verification]
flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
end
render :verify
else
render :new
end
end
def issue
# The Sortable default scope causes performance issues when used with find_by
@noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old
......
......@@ -221,19 +221,24 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
format.json do
render json: {
html: view_to_html_string('projects/merge_requests/show/_pipelines'),
pipelines: PipelineSerializer
.new(project: @project, user: @current_user)
.with_pagination(request, response)
.represent(@pipelines)
}
render json: PipelineSerializer
.new(project: @project, user: @current_user)
.represent(@pipelines)
end
end
end
def new
define_new_vars
respond_to do |format|
format.html { define_new_vars }
format.json do
define_pipelines_vars
render json: PipelineSerializer
.new(project: @project, user: @current_user)
.represent(@pipelines)
end
end
end
def new_diffs
......@@ -479,7 +484,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
deployment = environment.first_deployment_for(@merge_request.diff_head_commit)
stop_url =
if environment.stoppable? && can?(current_user, :create_deployment, environment)
if environment.stop_action? && can?(current_user, :create_deployment, environment)
stop_namespace_project_environment_path(project.namespace, project, environment)
end
......
......@@ -2,20 +2,13 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
def show
@ref = params[:ref] || @project.default_branch || 'master'
@badges = [Gitlab::Badge::Build::Status,
Gitlab::Badge::Coverage::Report]
@badges.map! do |badge|
badge.new(@project, @ref).metadata
end
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project, params: params)
end
def update
if @project.update_attributes(update_params)
flash[:notice] = "CI/CD Pipelines settings for '#{@project.name}' were successfully updated."
redirect_to namespace_project_pipelines_settings_path(@project.namespace, @project)
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
else
render 'show'
end
......
......@@ -5,11 +5,7 @@ class Projects::RunnersController < Projects::ApplicationController
layout 'project_settings'
def index
@project_runners = project.runners.ordered
@assignable_runners = current_user.ci_authorized_runners.
assignable_for(project).ordered.page(params[:page]).per(20)
@shared_runners = Ci::Runner.shared.active
@shared_runners_count = @shared_runners.count(:all)
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
end
def edit
......@@ -53,7 +49,7 @@ class Projects::RunnersController < Projects::ApplicationController
def toggle_shared_runners
project.toggle!(:shared_runners_enabled)
redirect_to namespace_project_runners_path(project.namespace, project)
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
end
protected
......
module Projects
module Settings
class CiCdController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
def show
define_runners_variables
define_secret_variables
define_triggers_variables
define_badges_variables
end
private
def define_runners_variables
@project_runners = @project.runners.ordered
@assignable_runners = current_user.ci_authorized_runners.
assignable_for(project).ordered.page(params[:page]).per(20)
@shared_runners = Ci::Runner.shared.active
@shared_runners_count = @shared_runners.count(:all)
end
def define_secret_variables
@variable = Ci::Variable.new
end
def define_triggers_variables
@triggers = @project.triggers
@trigger = Ci::Trigger.new
end
def define_badges_variables
@ref = params[:ref] || @project.default_branch || 'master'
@badges = [Gitlab::Badge::Build::Status,
Gitlab::Badge::Coverage::Report]
@badges.map! do |badge|
badge.new(@project, @ref).metadata
end
end
end
end
end
......@@ -4,8 +4,7 @@ class Projects::TriggersController < Projects::ApplicationController
layout 'project_settings'
def index
@triggers = project.triggers
@trigger = Ci::Trigger.new
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
end
def create
......@@ -13,17 +12,18 @@ class Projects::TriggersController < Projects::ApplicationController
@trigger.save
if @trigger.valid?
redirect_to namespace_project_triggers_path(@project.namespace, @project)
redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Trigger was created successfully.'
else
@triggers = project.triggers.select(&:persisted?)
render :index
render action: "show"
end
end
def destroy
trigger.destroy
flash[:alert] = "Trigger removed"
redirect_to namespace_project_triggers_path(@project.namespace, @project)
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
end
private
......
......@@ -4,7 +4,7 @@ class Projects::VariablesController < Projects::ApplicationController
layout 'project_settings'
def index
@variable = Ci::Variable.new
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
end
def show
......@@ -25,9 +25,10 @@ class Projects::VariablesController < Projects::ApplicationController
@variable = Ci::Variable.new(project_params)
if @variable.valid? && @project.variables << @variable
redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Variables were successfully updated.'
flash[:notice] = 'Variables were successfully updated.'
redirect_to namespace_project_settings_ci_cd_path(project.namespace, project)
else
render action: "index"
render "show"
end
end
......@@ -35,7 +36,7 @@ class Projects::VariablesController < Projects::ApplicationController
@key = @project.variables.find(params[:id])
@key.destroy
redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Variable was successfully removed.'
redirect_to namespace_project_settings_ci_cd_path(project.namespace, project), notice: 'Variable was successfully removed.'
end
private
......
......@@ -17,7 +17,7 @@ class RegistrationsController < Devise::RegistrationsController
if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha
super
else
flash[:alert] = 'There was an error with the reCAPTCHA. Please re-solve the reCAPTCHA.'
flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
flash.delete :recaptcha_error
render action: 'new'
end
......@@ -30,7 +30,7 @@ class RegistrationsController < Devise::RegistrationsController
format.html do
session.try(:destroy)
redirect_to new_user_session_path, notice: "Account successfully removed."
end
end
end
end
......
......@@ -211,8 +211,12 @@ module GitlabRoutingHelper
def project_settings_integrations_path(project, *args)
namespace_project_settings_integrations_path(project.namespace, project, *args)
end
def project_settings_members_path(project, *args)
namespace_project_settings_members_path(project.namespace, project, *args)
end
def project_settings_ci_cd_path(project, *args)
namespace_project_settings_ci_cd_path(project.namespace, project, *args)
end
end
......@@ -20,8 +20,8 @@ module MergeRequestsHelper
end
def mr_widget_refresh_url(mr)
if mr && mr.source_project
merge_widget_refresh_namespace_project_merge_request_url(mr.source_project.namespace, mr.source_project, mr)
if mr && mr.target_project
merge_widget_refresh_namespace_project_merge_request_url(mr.target_project.namespace, mr.target_project, mr)
else
''
end
......
......@@ -124,6 +124,10 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
numericality: { only_integer: true, greater_than: :housekeeping_full_repack_period }
validates :terminal_max_session_time,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates_each :restricted_visibility_levels do |record, attr, value|
unless value.nil?
value.each do |level|
......@@ -217,7 +221,8 @@ class ApplicationSetting < ActiveRecord::Base
signin_enabled: Settings.gitlab['signin_enabled'],
signup_enabled: Settings.gitlab['signup_enabled'],
two_factor_grace_period: 48,
user_default_external: false
user_default_external: false,
terminal_max_session_time: 0
}
end
......
......@@ -11,6 +11,7 @@ module Spammable
has_one :user_agent_detail, as: :subject, dependent: :destroy
attr_accessor :spam
attr_accessor :spam_log
after_validation :check_for_spam, on: :create
......@@ -34,9 +35,14 @@ module Spammable
end
def check_for_spam
if spam?
self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
end
error_msg = if Gitlab::Recaptcha.enabled?
"Your #{spammable_entity_type} has been recognized as spam. "\
"You can still submit it by solving Captcha."
else
"Your #{spammable_entity_type} has been recognized as spam and has been discarded."
end
self.errors.add(:base, error_msg) if spam?
end
def spammable_entity_type
......
......@@ -18,7 +18,7 @@ module TimeTrackable
validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false
validate :check_negative_time_spent
has_many :timelogs, as: :trackable, dependent: :destroy
has_many :timelogs, dependent: :destroy
end
def spend_time(options)
......
......@@ -91,7 +91,7 @@ class Deployment < ActiveRecord::Base
@stop_action ||= manual_actions.find_by(name: on_stop)
end
def stoppable?
def stop_action?
stop_action.present?
end
......
......@@ -118,15 +118,15 @@ class Environment < ActiveRecord::Base
external_url.gsub(/\A.*?:\/\//, '')
end
def stoppable?
def stop_action?
available? && stop_action.present?
end
def stop!(current_user)
return unless stoppable?
def stop_with_action!(current_user)
return unless available?
stop
stop_action.play(current_user)
stop!
stop_action.play(current_user) if stop_action
end
def actions_for(environment)
......
......@@ -241,7 +241,12 @@ class Group < Namespace
end
def refresh_members_authorized_projects
UserProjectAccessChangedService.new(users_with_parents.pluck(:id)).execute
UserProjectAccessChangedService.new(user_ids_for_project_authorizations).
execute
end
def user_ids_for_project_authorizations
users_with_parents.pluck(:id)
end
def members_with_parents
......
......@@ -218,6 +218,10 @@ class Namespace < ActiveRecord::Base
self.class.joins(:route).where('routes.path LIKE ?', "#{route.path}/%").reorder('routes.path ASC')
end
def user_ids_for_project_authorizations
[owner_id]
end
private
def repository_storage_paths
......
......@@ -55,6 +55,7 @@ class Project < ActiveRecord::Base
after_destroy :remove_pages
# update visibility_level of forks
after_update :update_forks_visibility_level
after_update :remove_mirror_repository_reference,
if: ->(project) { project.mirror? && project.import_url_updated? }
......
......@@ -23,7 +23,7 @@ class ChatSlashCommandsService < Service
def fields
[
{ type: 'text', name: 'token', placeholder: '' }
{ type: 'text', name: 'token', placeholder: 'XXxxXXxxXXxxXXxxXXxxXXxx' }
]
end
......
class KubernetesService < DeploymentService
include Gitlab::CurrentSettings
include Gitlab::Kubernetes
include ReactiveCaching
......@@ -110,7 +111,7 @@ class KubernetesService < DeploymentService
pods = data.fetch(:pods, nil)
filter_pods(pods, app: environment.slug).
flat_map { |pod| terminals_for_pod(api_url, namespace, pod) }.
map { |terminal| add_terminal_auth(terminal, token, ca_pem) }
each { |terminal| add_terminal_auth(terminal, terminal_auth) }
end
end
......@@ -170,4 +171,12 @@ class KubernetesService < DeploymentService
url.to_s
end
def terminal_auth
{
token: token,
ca_pem: ca_pem,
max_session_time: current_application_settings.terminal_max_session_time
}
end
end
......@@ -8,11 +8,11 @@ class MattermostSlashCommandsService < ChatSlashCommandsService
end
def title
'Mattermost Command'
'Mattermost slash commands'
end
def description
"Perform common operations on GitLab in Mattermost"
"Perform common operations in Mattermost"
end
def self.to_param
......
......@@ -2,11 +2,11 @@ class SlackSlashCommandsService < ChatSlashCommandsService
include TriggersHelper
def title
'Slack Command'
'Slack slash commands'
end
def description
"Perform common operations on GitLab in Slack"
"Perform common operations in Slack"
end
def self.to_param
......
class Timelog < ActiveRecord::Base
validates :time_spent, :user, presence: true
validate :issuable_id_is_present
belongs_to :trackable, polymorphic: true
belongs_to :issue
belongs_to :merge_request
belongs_to :user
def issuable
issue || merge_request
end
private
def issuable_id_is_present
if issue_id && merge_request_id
errors.add(:base, 'Only Issue ID or Merge Request ID is required')
elsif issuable.nil?
errors.add(:base, 'Issue or Merge Request ID is required')
end
end
end
......@@ -103,6 +103,9 @@ class User < ActiveRecord::Base
has_many :protected_branch_merge_access_levels, dependent: :destroy, class_name: ProtectedBranch::MergeAccessLevel
has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ProtectedBranch::PushAccessLevel
has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue"
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
#
# Validations
#
......
......@@ -7,7 +7,7 @@ class EnvironmentEntity < Grape::Entity
expose :external_url
expose :environment_type
expose :last_deployment, using: DeploymentEntity
expose :stoppable?
expose :stop_action?
expose :environment_path do |environment|
namespace_project_environment_path(
......
......@@ -8,10 +8,9 @@ module Ci
return unless has_ref?
environments.each do |environment|
next unless environment.stoppable?
next unless can?(current_user, :create_deployment, project)
environment.stop!(current_user)
environment.stop_with_action!(current_user)
end
end
......
......@@ -3,6 +3,8 @@ module Issues
def execute
@request = params.delete(:request)
@api = params.delete(:api)
@recaptcha_verified = params.delete(:recaptcha_verified)
@spam_log_id = params.delete(:spam_log_id)
issue_attributes = params.merge(merge_request_for_resolving_discussions: merge_request_for_resolving_discussions)
@issue = BuildService.new(project, current_user, issue_attributes).execute
......@@ -11,7 +13,13 @@ module Issues
end
def before_create(issuable)
issuable.spam = spam_service.check(@api)
if @recaptcha_verified
spam_log = current_user.spam_logs.find_by(id: @spam_log_id, title: issuable.title)
spam_log.update!(recaptcha_verified: true) if spam_log
else
issuable.spam = spam_service.check(@api)
issuable.spam_log = spam_service.spam_log
end
end
def after_create(issuable)
......@@ -35,7 +43,7 @@ module Issues
private
def spam_service
SpamService.new(@issue, @request)
@spam_service ||= SpamService.new(@issue, @request)
end
def user_agent_detail_service
......
......@@ -26,7 +26,7 @@ module Projects
end
def project_tree_saver
Gitlab::ImportExport::ProjectTreeSaver.new(project: project, shared: @shared)
Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared)
end
def uploads_saver
......
......@@ -25,9 +25,10 @@ module Projects
end
def transfer(project, new_namespace)
old_namespace = project.namespace
Project.transaction do
old_path = project.path_with_namespace
old_namespace = project.namespace
old_group = project.group
new_path = File.join(new_namespace.try(:path) || '', project.path)
......@@ -70,8 +71,11 @@ module Projects
project.old_path_with_namespace = old_path
SystemHooksService.new.execute_hooks_for(project, :transfer)
true
end
refresh_permissions(old_namespace, new_namespace)
true
end
def allowed_transfer?(current_user, project, namespace)
......@@ -80,5 +84,14 @@ module Projects
namespace.id != project.namespace_id &&
current_user.can?(:create_projects, namespace)
end
def refresh_permissions(old_namespace, new_namespace)
# This ensures we only schedule 1 job for every user that has access to
# the namespaces.
user_ids = old_namespace.user_ids_for_project_authorizations |
new_namespace.user_ids_for_project_authorizations
UserProjectAccessChangedService.new(user_ids).execute
end
end
end
class SpamService
attr_accessor :spammable, :request, :options
attr_reader :spam_log
def initialize(spammable, request = nil)
@spammable = spammable
......@@ -63,7 +64,7 @@ class SpamService
end
def create_spam_log(api)
SpamLog.create(
@spam_log = SpamLog.create!(
{
user_id: spammable_owner_id,
title: spammable.spam_title,
......
......@@ -118,16 +118,18 @@ module SystemNoteService
#
# Example Note text:
#
# "Changed estimate of this issue to 3d 5h"
# "removed time estimate"
#
# "changed time estimate to 3d 5h"
#
# Returns the created Note object
def change_time_estimate(noteable, project, author)
parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
body = if noteable.time_estimate == 0
"Removed time estimate on this #{noteable.human_class_name}"
"removed time estimate"
else
"Changed time estimate of this #{noteable.human_class_name} to #{parsed_time}"
"changed time estimate to #{parsed_time}"
end
create_note(noteable: noteable, project: project, author: author, note: body)
......@@ -142,7 +144,9 @@ module SystemNoteService
#
# Example Note text:
#
# "Added 2h 30m of time spent on this issue"
# "removed time spent"
#
# "added 2h 30m of time spent"
#
# Returns the created Note object
......@@ -150,11 +154,11 @@ module SystemNoteService
time_spent = noteable.time_spent
if time_spent == :reset
body = "Removed time spent on this #{noteable.human_class_name}"
body = "removed time spent"
else
parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
action = time_spent > 0 ? 'Added' : 'Subtracted'
body = "#{action} #{parsed_time} of time spent on this #{noteable.human_class_name}"
action = time_spent > 0 ? 'added' : 'subtracted'
body = "#{action} #{parsed_time} of time spent"
end
create_note(noteable: noteable, project: project, author: author, note: body)
......@@ -221,7 +225,7 @@ module SystemNoteService
end
def discussion_continued_in_issue(discussion, project, author, issue)
body = "Added #{issue.to_reference} to continue this discussion"
body = "created #{issue.to_reference} to continue this discussion"
note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
note_attributes[:type] = note_attributes.delete(:note_type)
......@@ -260,7 +264,7 @@ module SystemNoteService
#
# Example Note text:
#
# "made the issue confidential"
# "made the issue confidential"
#
# Returns the created Note object
def change_issue_confidentiality(issue, project, author)
......@@ -381,6 +385,7 @@ module SystemNoteService
# Returns Boolean
def cross_reference_disallowed?(noteable, mentioner)
return true if noteable.is_a?(ExternalIssue) && !noteable.project.jira_tracker_active?
return true if noteable.is_a?(Issuable) && (noteable.try(:closed?) || noteable.try(:merged?))
return false unless mentioner.is_a?(MergeRequest)
return false unless noteable.is_a?(Commit)
......
......@@ -573,5 +573,15 @@
.help-block
Number of Git pushes after which 'git gc' is run.
%fieldset
%legend Web terminal
.form-group
= f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :terminal_max_session_time, class: 'form-control'
.help-block
Maximum time for web terminal websocket connection (in seconds).
Set to 0 for unlimited time.
.form-actions
= f.submit 'Save', class: 'btn btn-save'
......@@ -27,7 +27,7 @@
= icon("search", class: "search-icon")
.dropdown
- toggle_text = 'Search for Namespace'
- toggle_text = 'Namespace'
- if params[:namespace_id].present?
- namespace = Namespace.find(params[:namespace_id])
- toggle_text = "#{namespace.kind}: #{namespace.path}"
......@@ -37,8 +37,10 @@
= dropdown_filter("Search for Namespace")
= dropdown_content
= dropdown_loading
= button_tag "Search", class: "btn btn-primary btn-search"
= render 'shared/projects/dropdown'
= link_to new_project_path, class: 'btn btn-new' do
New Project
= button_tag "Search", class: "btn btn-primary btn-search hide"
%ul.nav-links
- opts = params[:visibility_level].present? ? {} : { page: admin_projects_path }
......@@ -56,11 +58,6 @@
= link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) do
Public
.nav-controls
= render 'shared/projects/dropdown'
= link_to new_project_path, class: 'btn btn-new' do
New Project
.projects-list-holder
- if @projects.any?
%ul.projects-list.content-list
......
......@@ -13,6 +13,8 @@
= spam_log.source_ip
%td
= spam_log.via_api? ? 'Y' : 'N'
%td
= spam_log.recaptcha_verified ? 'Y' : 'N'
%td
= spam_log.noteable_type
%td
......
......@@ -10,6 +10,7 @@
%th User
%th Source IP
%th API?
%th Recaptcha verified?
%th Type
%th Title
%th Description
......
......@@ -23,7 +23,7 @@
= f.password_field :password, class: "form-control bottom", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters."
%p.gl-field-hint Minimum length is #{@minimum_password_length} characters
%div
- if current_application_settings.recaptcha_enabled
- if Gitlab::Recaptcha.enabled?
= recaptcha_tags
%div
= f.submit "Register", class: "btn-register btn"
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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