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