Commit cefeeaea authored by Clement Ho's avatar Clement Ho

Merge branch 'master' into bootstrap4

parents 59d6161e b1e9d98a
image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git-2.17-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6" image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git-2.17-chrome-65.0-node-8.x-yarn-1.2-postgresql-9.6"
.dedicated-runner: &dedicated-runner .dedicated-runner: &dedicated-runner
retry: 1 retry: 1
......
...@@ -126,7 +126,7 @@ Most issues will have labels for at least one of the following: ...@@ -126,7 +126,7 @@ Most issues will have labels for at least one of the following:
- Type: ~"feature proposal", ~bug, ~customer, etc. - Type: ~"feature proposal", ~bug, ~customer, etc.
- Subject: ~wiki, ~"container registry", ~ldap, ~api, ~frontend, etc. - Subject: ~wiki, ~"container registry", ~ldap, ~api, ~frontend, etc.
- Team: ~"CI/CD", ~Discussion, ~Edge, ~Platform, etc. - Team: ~"CI/CD", ~Discussion, ~Edge, ~Platform, etc.
- Priority: ~Deliverable, ~Stretch, ~"Next Patch Release" - Milestone: ~Deliverable, ~Stretch, ~"Next Patch Release"
All labels, their meaning and priority are defined on the All labels, their meaning and priority are defined on the
[labels page][labels-page]. [labels page][labels-page].
...@@ -185,10 +185,10 @@ indicate if an issue needs backend work, frontend work, or both. ...@@ -185,10 +185,10 @@ indicate if an issue needs backend work, frontend work, or both.
Team labels are always capitalized so that they show up as the first label for Team labels are always capitalized so that they show up as the first label for
any issue. any issue.
### Priority labels (~Deliverable, ~Stretch, ~"Next Patch Release") ### Milestone labels (~Deliverable, ~Stretch, ~"Next Patch Release")
Priority labels help us clearly communicate expectations of the work for the Milestone labels help us clearly communicate expectations of the work for the
release. There are two levels of priority labels: release. There are three levels of Milestone labels:
- ~Deliverable: Issues that are expected to be delivered in the current - ~Deliverable: Issues that are expected to be delivered in the current
milestone. milestone.
...@@ -203,16 +203,46 @@ Each issue scheduled for the current milestone should be labeled ~Deliverable ...@@ -203,16 +203,46 @@ Each issue scheduled for the current milestone should be labeled ~Deliverable
or ~"Stretch". Any open issue for a previous milestone should be labeled or ~"Stretch". Any open issue for a previous milestone should be labeled
~"Next Patch Release", or otherwise rescheduled to a different milestone. ~"Next Patch Release", or otherwise rescheduled to a different milestone.
### Severity labels (~S1, ~S2, etc.) ### Bug Priority labels (~P1, ~P2, ~P3 & etc.)
Severity labels help us clearly communicate the impact of a ~bug on users. Bug Priority labels help us define the time a ~bug fix should be completed. Priority determines how quickly the defect turnaround time must be. If there are multiple defects, the priority decides which defect has to be fixed immediately versus later.
This label documents the planned timeline & urgency which is used to measure against our actual SLA on delivering ~bug fixes.
| Label | Meaning | Example | | Label | Meaning | Estimate time to fix | Guidance |
|-------|------------------------------------------|---------| |-------|-----------------|------------------------------------------------------------------|----------|
| ~S1 | Feature broken, no workaround | Unable to create an issue | | ~P1 | Urgent Priority | The current release | |
| ~S2 | Feature broken, workaround unacceptable | Can push commits, but only via the command line | | ~P2 | High Priority | The next release | |
| ~S3 | Feature broken, workaround acceptable | Can create merge requests only from the Merge Requests page, not through the Issue | | ~P3 | Medium Priority | Within the next 3 releases (approx one quarter) | |
| ~S4 | Cosmetic issue | Label colors are incorrect / not being displayed | | ~P4 | Low Priority | Anything outside the next 3 releases (approx beyond one quarter) | The issue is prominent but does not impact user workflow and a workaround is documented |
#### Specific Priority guidance
| Label | Availability / Performance |
|-------|--------------------------------------------------------------|
| ~P1 | |
| ~P2 | The issue is (almost) guaranteed to occur in the near future |
| ~P3 | The issue is likely to occur in the near future |
| ~P4 | The issue _may_ occur but it's not likely |
### Bug Severity labels (~S1, ~S2, ~S3 & etc.)
Severity labels help us clearly communicate the impact of a ~bug on users.
| Label | Meaning | Impact of the defect | Example |
|-------|-------------------|-------------------------------------------------------|---------|
| ~S1 | Blocker | Outage, broken feature with no workaround | Unable to create an issue. Data corruption/loss. Security breach. |
| ~S2 | Critical Severity | Broken Feature, workaround too complex & unacceptable | Can push commits, but only via the command line. |
| ~S3 | Major Severity | Broken Feature, workaround acceptable | Can create merge requests only from the Merge Requests page, not through the Issue. |
| ~S4 | Low Severity | Functionality inconvenience or cosmetic issue | Label colors are incorrect / not being displayed. |
#### Specific Severity guidance
| Label | Security Impact |
|-------|-------------------------------------------------------------------|
| ~S1 | >50% customers impacted (possible company extinction level event) |
| ~S2 | Multiple customers impacted (but not apocalyptic) |
| ~S3 | A single customer impacted |
| ~S4 | No customer impact, or expected impact within 30 days |
### Label for community contributors (~"Accepting Merge Requests") ### Label for community contributors (~"Accepting Merge Requests")
......
...@@ -82,7 +82,7 @@ gem 'net-ldap' ...@@ -82,7 +82,7 @@ gem 'net-ldap'
# Git Wiki # Git Wiki
# Required manually in config/initializers/gollum.rb to control load order # Required manually in config/initializers/gollum.rb to control load order
gem 'gitlab-gollum-lib', '~> 4.2' gem 'gitlab-gollum-lib', '~> 4.2', require: false
gem 'gitlab-gollum-rugged_adapter', '~> 0.4.4', require: false gem 'gitlab-gollum-rugged_adapter', '~> 0.4.4', require: false
...@@ -140,7 +140,7 @@ gem 'creole', '~> 0.5.0' ...@@ -140,7 +140,7 @@ gem 'creole', '~> 0.5.0'
gem 'wikicloth', '0.8.1' gem 'wikicloth', '0.8.1'
gem 'asciidoctor', '~> 1.5.6' gem 'asciidoctor', '~> 1.5.6'
gem 'asciidoctor-plantuml', '0.0.8' gem 'asciidoctor-plantuml', '0.0.8'
gem 'rouge', '~> 2.0' gem 'rouge', '~> 3.1'
gem 'truncato', '~> 0.7.9' gem 'truncato', '~> 0.7.9'
gem 'bootstrap_form', '~> 2.7.0' gem 'bootstrap_form', '~> 2.7.0'
gem 'nokogiri', '~> 1.8.2' gem 'nokogiri', '~> 1.8.2'
...@@ -415,7 +415,7 @@ group :ed25519 do ...@@ -415,7 +415,7 @@ group :ed25519 do
end end
# Gitaly GRPC client # Gitaly GRPC client
gem 'gitaly-proto', '~> 0.94.0', require: 'gitaly' gem 'gitaly-proto', '~> 0.97.0', require: 'gitaly'
gem 'grpc', '~> 1.10.0' gem 'grpc', '~> 1.10.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed # Locked until https://github.com/google/protobuf/issues/4210 is closed
...@@ -434,5 +434,3 @@ gem 'grape_logging', '~> 1.7' ...@@ -434,5 +434,3 @@ gem 'grape_logging', '~> 1.7'
# Asset synchronization # Asset synchronization
gem 'asset_sync', '~> 2.2.0' gem 'asset_sync', '~> 2.2.0'
gem 'goldiloader', '~> 2.0'
...@@ -290,9 +290,9 @@ GEM ...@@ -290,9 +290,9 @@ GEM
po_to_json (>= 1.0.0) po_to_json (>= 1.0.0)
rails (>= 3.2.0) rails (>= 3.2.0)
gherkin-ruby (0.3.2) gherkin-ruby (0.3.2)
gitaly-proto (0.94.0) gitaly-proto (0.97.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
grpc (~> 1.0) grpc (~> 1.10)
github-linguist (5.3.3) github-linguist (5.3.3)
charlock_holmes (~> 0.7.5) charlock_holmes (~> 0.7.5)
escape_utils (~> 1.1.0) escape_utils (~> 1.1.0)
...@@ -303,12 +303,12 @@ GEM ...@@ -303,12 +303,12 @@ GEM
flowdock (~> 0.7) flowdock (~> 0.7)
gitlab-grit (>= 2.4.1) gitlab-grit (>= 2.4.1)
multi_json multi_json
gitlab-gollum-lib (4.2.7.1) gitlab-gollum-lib (4.2.7.2)
gemojione (~> 3.2) gemojione (~> 3.2)
github-markup (~> 1.6) github-markup (~> 1.6)
gollum-grit_adapter (~> 1.0) gollum-grit_adapter (~> 1.0)
nokogiri (>= 1.6.1, < 2.0) nokogiri (>= 1.6.1, < 2.0)
rouge (~> 2.1) rouge (~> 3.1)
sanitize (~> 2.1) sanitize (~> 2.1)
stringex (~> 2.6) stringex (~> 2.6)
gitlab-gollum-rugged_adapter (0.4.4) gitlab-gollum-rugged_adapter (0.4.4)
...@@ -331,9 +331,6 @@ GEM ...@@ -331,9 +331,6 @@ GEM
rubyntlm (~> 0.5) rubyntlm (~> 0.5)
globalid (0.4.1) globalid (0.4.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
goldiloader (2.0.1)
activerecord (>= 4.2, < 5.2)
activesupport (>= 4.2, < 5.2)
gollum-grit_adapter (1.0.1) gollum-grit_adapter (1.0.1)
gitlab-grit (~> 2.7, >= 2.7.1) gitlab-grit (~> 2.7, >= 2.7.1)
gon (6.1.0) gon (6.1.0)
...@@ -750,7 +747,7 @@ GEM ...@@ -750,7 +747,7 @@ GEM
retriable (3.1.1) retriable (3.1.1)
rinku (2.0.0) rinku (2.0.0)
rotp (2.1.2) rotp (2.1.2)
rouge (2.2.1) rouge (3.1.1)
rqrcode (0.7.0) rqrcode (0.7.0)
chunky_png chunky_png
rqrcode-rails3 (0.1.7) rqrcode-rails3 (0.1.7)
...@@ -1064,7 +1061,7 @@ DEPENDENCIES ...@@ -1064,7 +1061,7 @@ DEPENDENCIES
gettext (~> 3.2.2) gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3) gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.94.0) gitaly-proto (~> 0.97.0)
github-linguist (~> 5.3.3) github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2) gitlab-gollum-lib (~> 4.2)
...@@ -1072,7 +1069,6 @@ DEPENDENCIES ...@@ -1072,7 +1069,6 @@ DEPENDENCIES
gitlab-markup (~> 1.6.2) gitlab-markup (~> 1.6.2)
gitlab-styles (~> 2.3) gitlab-styles (~> 2.3)
gitlab_omniauth-ldap (~> 2.0.4) gitlab_omniauth-ldap (~> 2.0.4)
goldiloader (~> 2.0)
gon (~> 6.1.0) gon (~> 6.1.0)
google-api-client (~> 0.19.8) google-api-client (~> 0.19.8)
google-protobuf (= 3.5.1) google-protobuf (= 3.5.1)
...@@ -1164,7 +1160,7 @@ DEPENDENCIES ...@@ -1164,7 +1160,7 @@ DEPENDENCIES
redis-rails (~> 5.0.2) redis-rails (~> 5.0.2)
request_store (~> 1.3) request_store (~> 1.3)
responders (~> 2.0) responders (~> 2.0)
rouge (~> 2.0) rouge (~> 3.1)
rqrcode-rails3 (~> 0.1.7) rqrcode-rails3 (~> 0.1.7)
rspec-parameterized rspec-parameterized
rspec-rails (~> 3.6.0) rspec-rails (~> 3.6.0)
......
...@@ -69,7 +69,7 @@ GEM ...@@ -69,7 +69,7 @@ GEM
unf unf
ast (2.4.0) ast (2.4.0)
atomic (1.1.100) atomic (1.1.100)
attr_encrypted (3.0.3) attr_encrypted (3.1.0)
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
attr_required (1.0.1) attr_required (1.0.1)
autoprefixer-rails (8.1.0.1) autoprefixer-rails (8.1.0.1)
...@@ -291,9 +291,9 @@ GEM ...@@ -291,9 +291,9 @@ GEM
po_to_json (>= 1.0.0) po_to_json (>= 1.0.0)
rails (>= 3.2.0) rails (>= 3.2.0)
gherkin-ruby (0.3.2) gherkin-ruby (0.3.2)
gitaly-proto (0.94.0) gitaly-proto (0.97.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
grpc (~> 1.0) grpc (~> 1.10)
github-linguist (5.3.3) github-linguist (5.3.3)
charlock_holmes (~> 0.7.5) charlock_holmes (~> 0.7.5)
escape_utils (~> 1.1.0) escape_utils (~> 1.1.0)
...@@ -304,6 +304,17 @@ GEM ...@@ -304,6 +304,17 @@ GEM
flowdock (~> 0.7) flowdock (~> 0.7)
gitlab-grit (>= 2.4.1) gitlab-grit (>= 2.4.1)
multi_json multi_json
gitlab-gollum-lib (4.2.7.1)
gemojione (~> 3.2)
github-markup (~> 1.6)
gollum-grit_adapter (~> 1.0)
nokogiri (>= 1.6.1, < 2.0)
rouge (~> 2.1)
sanitize (~> 2.1)
stringex (~> 2.6)
gitlab-gollum-rugged_adapter (0.4.4)
mime-types (>= 1.15)
rugged (~> 0.25)
gitlab-grit (2.8.2) gitlab-grit (2.8.2)
charlock_holmes (~> 0.6) charlock_holmes (~> 0.6)
diff-lcs (~> 1.1) diff-lcs (~> 1.1)
...@@ -321,22 +332,8 @@ GEM ...@@ -321,22 +332,8 @@ GEM
rubyntlm (~> 0.5) rubyntlm (~> 0.5)
globalid (0.4.1) globalid (0.4.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
goldiloader (2.0.1)
activerecord (>= 4.2, < 5.2)
activesupport (>= 4.2, < 5.2)
gollum-grit_adapter (1.0.1) gollum-grit_adapter (1.0.1)
gitlab-grit (~> 2.7, >= 2.7.1) gitlab-grit (~> 2.7, >= 2.7.1)
gollum-lib (4.2.7)
gemojione (~> 3.2)
github-markup (~> 1.6)
gollum-grit_adapter (~> 1.0)
nokogiri (>= 1.6.1, < 2.0)
rouge (~> 2.1)
sanitize (~> 2.1)
stringex (~> 2.6)
gollum-rugged_adapter (0.4.4)
mime-types (>= 1.15)
rugged (~> 0.25)
gon (6.1.0) gon (6.1.0)
actionpack (>= 3.0) actionpack (>= 3.0)
json json
...@@ -1009,7 +1006,7 @@ DEPENDENCIES ...@@ -1009,7 +1006,7 @@ DEPENDENCIES
asciidoctor (~> 1.5.6) asciidoctor (~> 1.5.6)
asciidoctor-plantuml (= 0.0.8) asciidoctor-plantuml (= 0.0.8)
asset_sync (~> 2.2.0) asset_sync (~> 2.2.0)
attr_encrypted (~> 3.0.0) attr_encrypted (~> 3.1.0)
awesome_print (~> 1.2.0) awesome_print (~> 1.2.0)
babosa (~> 1.0.2) babosa (~> 1.0.2)
base32 (~> 0.3.0) base32 (~> 0.3.0)
...@@ -1069,15 +1066,14 @@ DEPENDENCIES ...@@ -1069,15 +1066,14 @@ DEPENDENCIES
gettext (~> 3.2.2) gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3) gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.94.0) gitaly-proto (~> 0.97.0)
github-linguist (~> 5.3.3) github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
gitlab-gollum-rugged_adapter (~> 0.4.4)
gitlab-markup (~> 1.6.2) gitlab-markup (~> 1.6.2)
gitlab-styles (~> 2.3) gitlab-styles (~> 2.3)
gitlab_omniauth-ldap (~> 2.0.4) gitlab_omniauth-ldap (~> 2.0.4)
goldiloader (~> 2.0)
gollum-lib (~> 4.2)
gollum-rugged_adapter (~> 0.4.4)
gon (~> 6.1.0) gon (~> 6.1.0)
google-api-client (~> 0.19.8) google-api-client (~> 0.19.8)
google-protobuf (= 3.5.1) google-protobuf (= 3.5.1)
......
Copyright (c) 2011-2017 GitLab B.V. Copyright GitLab B.V.
With regard to the GitLab Software: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
all copies or substantial portions of the Software. \ No newline at end of file
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
For all third party components incorporated into the GitLab Software, those
components are licensed under the original license provided by the owner of the
applicable component.
\ No newline at end of file
...@@ -67,6 +67,12 @@ You can access a new installation with the login **`root`** and password **`5ive ...@@ -67,6 +67,12 @@ You can access a new installation with the login **`root`** and password **`5ive
GitLab is an open source project and we are very happy to accept community contributions. Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md) for details. GitLab is an open source project and we are very happy to accept community contributions. Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md) for details.
## Licensing
GitLab Community Edition (CE) is available freely under the MIT Expat license.
All third party components incorporated into the GitLab Software are licensed under the original license provided by the owner of the applicable component.
## Install a development environment ## Install a development environment
To work on GitLab itself, we recommend setting up your development environment with [the GitLab Development Kit](https://gitlab.com/gitlab-org/gitlab-development-kit). To work on GitLab itself, we recommend setting up your development environment with [the GitLab Development Kit](https://gitlab.com/gitlab-org/gitlab-development-kit).
......
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
import $ from 'jquery'; import $ from 'jquery';
import Pikaday from 'pikaday'; import Pikaday from 'pikaday';
import { __ } from '~/locale';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import { timeFor } from './lib/utils/datetime_utility';
import { parsePikadayDate, pikadayToString } from './lib/utils/datefix'; import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
class DueDateSelect { class DueDateSelect {
...@@ -14,6 +16,7 @@ class DueDateSelect { ...@@ -14,6 +16,7 @@ class DueDateSelect {
this.$dropdownParent = $dropdownParent; this.$dropdownParent = $dropdownParent;
this.$datePicker = $dropdownParent.find('.js-due-date-calendar'); this.$datePicker = $dropdownParent.find('.js-due-date-calendar');
this.$block = $block; this.$block = $block;
this.$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
this.$selectbox = $dropdown.closest('.selectbox'); this.$selectbox = $dropdown.closest('.selectbox');
this.$value = $block.find('.value'); this.$value = $block.find('.value');
this.$valueContent = $block.find('.value-content'); this.$valueContent = $block.find('.value-content');
...@@ -128,7 +131,8 @@ class DueDateSelect { ...@@ -128,7 +131,8 @@ class DueDateSelect {
submitSelectedDate(isDropdown) { submitSelectedDate(isDropdown) {
const selectedDateValue = this.datePayload[this.abilityName].due_date; const selectedDateValue = this.datePayload[this.abilityName].due_date;
const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value'; const hasDueDate = this.displayedDate !== 'No due date';
const displayedDateStyle = hasDueDate ? 'bold' : 'no-value';
this.$loading.removeClass('hidden').fadeIn(); this.$loading.removeClass('hidden').fadeIn();
...@@ -145,10 +149,13 @@ class DueDateSelect { ...@@ -145,10 +149,13 @@ class DueDateSelect {
return axios.put(this.issueUpdateURL, this.datePayload) return axios.put(this.issueUpdateURL, this.datePayload)
.then(() => { .then(() => {
const tooltipText = hasDueDate ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` : __('Due date');
if (isDropdown) { if (isDropdown) {
this.$dropdown.trigger('loaded.gl.dropdown'); this.$dropdown.trigger('loaded.gl.dropdown');
this.$dropdown.dropdown('toggle'); this.$dropdown.dropdown('toggle');
} }
this.$sidebarCollapsedValue.attr('data-original-title', tooltipText);
return this.$loading.fadeOut(); return this.$loading.fadeOut();
}); });
} }
......
<script> <script>
import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import { pluralize } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
export default { export default {
components: { components: {
icon, Icon,
},
directives: {
tooltip,
}, },
props: { props: {
file: { file: {
type: Object, type: Object,
required: true, required: true,
}, },
showTooltip: {
type: Boolean,
required: false,
default: false,
},
showStagedIcon: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
changedIcon() { changedIcon() {
return this.file.tempFile ? 'file-addition' : 'file-modified'; const suffix = this.file.staged && !this.showStagedIcon ? '-solid' : '';
return this.file.tempFile ? `file-addition${suffix}` : `file-modified${suffix}`;
},
stagedIcon() {
return `${this.changedIcon}-solid`;
}, },
changedIconClass() { changedIconClass() {
return `multi-${this.changedIcon}`; return `multi-${this.changedIcon} prepend-left-5 pull-left`;
},
tooltipTitle() {
if (!this.showTooltip) return undefined;
const type = this.file.tempFile ? 'addition' : 'modification';
if (this.file.changed && !this.file.staged) {
return sprintf(__('Unstaged %{type}'), {
type,
});
} else if (!this.file.changed && this.file.staged) {
return sprintf(__('Staged %{type}'), {
type,
});
} else if (this.file.changed && this.file.staged) {
return sprintf(__('Unstaged and staged %{type}'), {
type: pluralize(type),
});
}
return undefined;
}, },
}, },
}; };
</script> </script>
<template> <template>
<icon <span
:name="changedIcon" v-tooltip
:size="12" :title="tooltipTitle"
:css-classes="`ide-file-changed-icon ${changedIconClass}`" data-container="body"
/> data-placement="right"
class="ide-file-changed-icon"
>
<icon
v-if="file.staged && showStagedIcon"
:name="stagedIcon"
:size="12"
:css-classes="changedIconClass"
/>
<icon
v-if="file.changed || file.tempFile || (file.staged && !showStagedIcon)"
:name="changedIcon"
:size="12"
:css-classes="changedIconClass"
/>
</span>
</template> </template>
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
noChangesStateSvgPath: {
type: String,
required: true,
},
committedStateSvgPath: {
type: String,
required: true,
},
},
computed: {
...mapState(['lastCommitMsg', 'rightPanelCollapsed']),
...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
statusSvg() {
return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath;
},
},
methods: {
...mapActions(['toggleRightPanelCollapsed']),
},
};
</script>
<template>
<div
class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state"
>
<header
class="multi-file-commit-panel-header"
:class="{
'is-collapsed': rightPanelCollapsed,
}"
>
<button
v-tooltip
:title="collapseButtonTooltip"
data-container="body"
data-placement="left"
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
:aria-label="__('Toggle sidebar')"
@click.stop="toggleRightPanelCollapsed"
>
<icon
:name="collapseButtonIcon"
:size="18"
/>
</button>
</header>
<div
class="ide-commit-empty-state-container"
v-if="!rightPanelCollapsed"
>
<div class="svg-content svg-80">
<img :src="statusSvg" />
</div>
<div class="append-right-default prepend-left-default">
<div
class="text-content text-center"
v-if="!lastCommitMsg"
>
<h4>
{{ __('No changes') }}
</h4>
<p>
{{ __('Edit files in the editor and commit changes here') }}
</p>
</div>
<div
class="text-content text-center"
v-else
>
<h4>
{{ __('All changes are committed') }}
</h4>
<p v-html="lastCommitMsg"></p>
</div>
</div>
</div>
</div>
</template>
<script> <script>
import { mapState } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import icon from '~/vue_shared/components/icon.vue'; import { __, sprintf } from '~/locale';
import listItem from './list_item.vue'; import Icon from '~/vue_shared/components/icon.vue';
import listCollapsed from './list_collapsed.vue'; import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
import ListCollapsed from './list_collapsed.vue';
export default { export default {
components: { components: {
icon, Icon,
listItem, ListItem,
listCollapsed, ListCollapsed,
},
directives: {
tooltip,
},
props: {
title: {
type: String,
required: true,
}, },
props: { fileList: {
title: { type: Array,
type: String, required: true,
required: true,
},
fileList: {
type: Array,
required: true,
},
}, },
computed: { showToggle: {
...mapState([ type: Boolean,
'currentProjectId', required: false,
'currentBranchId', default: true,
'rightPanelCollapsed',
]),
isCommitInfoShown() {
return this.rightPanelCollapsed || this.fileList.length;
},
}, },
methods: { iconName: {
toggleCollapsed() { type: String,
this.$emit('toggleCollapsed'); required: true,
},
}, },
}; action: {
type: String,
required: true,
},
actionBtnText: {
type: String,
required: true,
},
itemActionComponent: {
type: String,
required: true,
},
stagedList: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['rightPanelCollapsed']),
...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
titleText() {
return sprintf(__('%{title} changes'), {
title: this.title,
});
},
},
methods: {
...mapActions(['toggleRightPanelCollapsed', 'stageAllChanges', 'unstageAllChanges']),
actionBtnClicked() {
this[this.action]();
},
},
};
</script> </script>
<template> <template>
<div <div
class="ide-commit-list-container"
:class="{ :class="{
'multi-file-commit-list': isCommitInfoShown 'is-collapsed': rightPanelCollapsed,
}" }"
> >
<header
class="multi-file-commit-panel-header"
>
<div
v-if="!rightPanelCollapsed"
class="multi-file-commit-panel-header-title"
:class="{
'append-right-10': showToggle,
}"
>
<icon
v-once
:name="iconName"
:size="18"
/>
{{ titleText }}
<button
type="button"
class="btn btn-blank btn-link ide-staged-action-btn"
@click="actionBtnClicked"
>
{{ actionBtnText }}
</button>
</div>
<button
v-if="showToggle"
v-tooltip
:title="collapseButtonTooltip"
data-container="body"
data-placement="left"
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
:aria-label="__('Toggle sidebar')"
@click.stop="toggleRightPanelCollapsed"
>
<icon
:name="collapseButtonIcon"
:size="18"
/>
</button>
</header>
<list-collapsed <list-collapsed
v-if="rightPanelCollapsed" v-if="rightPanelCollapsed"
:files="fileList"
:icon-name="iconName"
:title="title"
/> />
<template v-else> <template v-else>
<ul <ul
v-if="fileList.length" v-if="fileList.length"
class="list-unstyled append-bottom-0" class="multi-file-commit-list list-unstyled append-bottom-0"
> >
<li <li
v-for="file in fileList" v-for="file in fileList"
...@@ -58,9 +134,18 @@ ...@@ -58,9 +134,18 @@
> >
<list-item <list-item
:file="file" :file="file"
:action-component="itemActionComponent"
:key-prefix="title"
:staged-list="stagedList"
/> />
</li> </li>
</ul> </ul>
<p
v-else
class="multi-file-commit-list help-block"
>
{{ __('No changes') }}
</p>
</template> </template>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue';
import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip';
import { sprintf, n__, __ } from '~/locale';
export default { export default {
components: { components: {
icon, Icon,
},
directives: {
tooltip,
},
props: {
files: {
type: Array,
required: true,
}, },
computed: { iconName: {
...mapGetters([ type: String,
'addedFiles', required: true,
'modifiedFiles',
]),
}, },
}; title: {
type: String,
required: true,
},
},
computed: {
addedFilesLength() {
return this.files.filter(f => f.tempFile).length;
},
modifiedFilesLength() {
return this.files.filter(f => !f.tempFile).length;
},
addedFilesIconClass() {
return this.addedFilesLength ? 'multi-file-addition' : '';
},
modifiedFilesClass() {
return this.modifiedFilesLength ? 'multi-file-modified' : '';
},
additionsTooltip() {
return sprintf(n__('1 %{type} addition', '%d %{type} additions', this.addedFilesLength), {
type: this.title.toLowerCase(),
});
},
modifiedTooltip() {
return sprintf(
n__('1 %{type} modification', '%d %{type} modifications', this.modifiedFilesLength),
{ type: this.title.toLowerCase() },
);
},
titleTooltip() {
return sprintf(__('%{title} changes'), { title: this.title });
},
additionIconName() {
return this.title.toLowerCase() === 'staged' ? 'file-addition-solid' : 'file-addition';
},
modifiedIconName() {
return this.title.toLowerCase() === 'staged' ? 'file-modified-solid' : 'file-modified';
},
},
};
</script> </script>
<template> <template>
<div <div
class="multi-file-commit-list-collapsed text-center" class="multi-file-commit-list-collapsed text-center"
> >
<icon <div
name="file-addition" v-tooltip
:size="18" :title="titleTooltip"
css-classes="multi-file-addition append-bottom-10" data-container="body"
/> data-placement="left"
{{ addedFiles.length }} class="append-bottom-15"
<icon >
name="file-modified" <icon
:size="18" v-once
css-classes="multi-file-modified prepend-top-10 append-bottom-10" :name="iconName"
/> :size="18"
{{ modifiedFiles.length }} />
</div>
<div
v-tooltip
:title="additionsTooltip"
data-container="body"
data-placement="left"
class="append-bottom-10"
>
<icon
:name="additionIconName"
:size="18"
:css-classes="addedFilesIconClass"
/>
</div>
{{ addedFilesLength }}
<div
v-tooltip
:title="modifiedTooltip"
data-container="body"
data-placement="left"
class="prepend-top-10 append-bottom-10"
>
<icon
:name="modifiedIconName"
:size="18"
:css-classes="modifiedFilesClass"
/>
</div>
{{ modifiedFilesLength }}
</div> </div>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import StageButton from './stage_button.vue';
import UnstageButton from './unstage_button.vue';
export default { export default {
components: { components: {
Icon, Icon,
StageButton,
UnstageButton,
}, },
props: { props: {
file: { file: {
type: Object, type: Object,
required: true, required: true,
}, },
actionComponent: {
type: String,
required: true,
},
keyPrefix: {
type: String,
required: false,
default: '',
},
stagedList: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
iconName() { iconName() {
return this.file.tempFile ? 'file-addition' : 'file-modified'; const prefix = this.stagedList ? '-solid' : '';
return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`;
}, },
iconClass() { iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; return `multi-file-${this.file.tempFile ? 'additions' : 'modified'} append-right-8`;
}, },
}, },
methods: { methods: {
...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']), ...mapActions([
openFileInEditor(file) { 'discardFileChanges',
return this.openPendingTab(file).then(changeViewer => { 'updateViewer',
'openPendingTab',
'unstageChange',
'stageChange',
]),
openFileInEditor() {
return this.openPendingTab({
file: this.file,
keyPrefix: this.keyPrefix.toLowerCase(),
}).then(changeViewer => {
if (changeViewer) { if (changeViewer) {
this.updateViewer('diff'); this.updateViewer('diff');
} }
}); });
}, },
fileAction() {
if (this.file.staged) {
this.unstageChange(this.file.path);
} else {
this.stageChange(this.file.path);
}
},
}, },
}; };
</script> </script>
...@@ -38,7 +73,9 @@ export default { ...@@ -38,7 +73,9 @@ export default {
<button <button
type="button" type="button"
class="multi-file-commit-list-path" class="multi-file-commit-list-path"
@click="openFileInEditor(file)"> @dblclick="fileAction"
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path"> <span class="multi-file-commit-list-file-path">
<icon <icon
:name="iconName" :name="iconName"
...@@ -47,12 +84,9 @@ export default { ...@@ -47,12 +84,9 @@ export default {
/>{{ file.path }} />{{ file.path }}
</span> </span>
</button> </button>
<button <component
type="button" :is="actionComponent"
class="btn btn-blank multi-file-discard-btn" :path="file.path"
@click="discardFileChanges(file.path)" />
>
Discard
</button>
</div> </div>
</template> </template>
<script>
import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
path: {
type: String,
required: true,
},
},
methods: {
...mapActions(['stageChange', 'discardFileChanges']),
},
};
</script>
<template>
<div
v-once
class="multi-file-discard-btn"
>
<button
v-tooltip
type="button"
class="btn btn-blank append-right-5"
:aria-label="__('Stage changes')"
:title="__('Stage changes')"
data-container="body"
@click.stop="stageChange(path)"
>
<icon
name="mobile-issue-close"
:size="12"
/>
</button>
<button
v-tooltip
type="button"
class="btn btn-blank"
:aria-label="__('Discard changes')"
:title="__('Discard changes')"
data-container="body"
@click.stop="discardFileChanges(path)"
>
<icon
name="remove"
:size="12"
/>
</button>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
path: {
type: String,
required: true,
},
},
methods: {
...mapActions(['unstageChange']),
},
};
</script>
<template>
<div
v-once
class="multi-file-discard-btn"
>
<button
v-tooltip
type="button"
class="btn btn-blank"
:aria-label="__('Unstage changes')"
:title="__('Unstage changes')"
data-container="body"
@click="unstageChange(path)"
>
<icon
name="history"
:size="12"
/>
</button>
</div>
</template>
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import panelResizer from '~/vue_shared/components/panel_resizer.vue'; import panelResizer from '~/vue_shared/components/panel_resizer.vue';
import repoCommitSection from './repo_commit_section.vue'; import repoCommitSection from './repo_commit_section.vue';
...@@ -22,13 +21,6 @@ export default { ...@@ -22,13 +21,6 @@ export default {
required: true, required: true,
}, },
}, },
computed: {
...mapState(['changedFiles', 'rightPanelCollapsed']),
...mapGetters(['currentIcon']),
},
methods: {
...mapActions(['setPanelCollapsedStatus']),
},
}; };
</script> </script>
...@@ -41,40 +33,6 @@ export default { ...@@ -41,40 +33,6 @@ export default {
<div <div
class="multi-file-commit-panel-section" class="multi-file-commit-panel-section"
> >
<header
class="multi-file-commit-panel-header"
:class="{
'is-collapsed': rightPanelCollapsed,
}"
>
<div
class="multi-file-commit-panel-header-title"
v-if="!rightPanelCollapsed"
>
<div
v-if="changedFiles.length"
>
<icon
name="list-bulleted"
:size="18"
/>
Staged
</div>
</div>
<button
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
@click.stop="setPanelCollapsedStatus({
side: 'right',
collapsed: !rightPanelCollapsed,
})"
>
<icon
:name="currentIcon"
:size="18"
/>
</button>
</header>
<repo-commit-section <repo-commit-section
:no-changes-state-svg-path="noChangesStateSvgPath" :no-changes-state-svg-path="noChangesStateSvgPath"
:committed-state-svg-path="committedStateSvgPath" :committed-state-svg-path="committedStateSvgPath"
......
...@@ -22,13 +22,6 @@ export default { ...@@ -22,13 +22,6 @@ export default {
<template> <template>
<div class="ide-status-bar"> <div class="ide-status-bar">
<div class="ref-name">
<icon
name="branch"
:size="12"
/>
{{ file.branchId }}
</div>
<div> <div>
<div v-if="file.lastCommit && file.lastCommit.id"> <div v-if="file.lastCommit && file.lastCommit.id">
Last commit: Last commit:
......
<script> <script>
import { mapState, mapActions, mapGetters } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue';
import commitFilesList from './commit_sidebar/list.vue'; import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
import CommitMessageField from './commit_sidebar/message_field.vue'; import CommitMessageField from './commit_sidebar/message_field.vue';
import * as consts from '../stores/modules/commit/constants'; import * as consts from '../stores/modules/commit/constants';
import Actions from './commit_sidebar/actions.vue'; import Actions from './commit_sidebar/actions.vue';
...@@ -12,8 +13,9 @@ import Actions from './commit_sidebar/actions.vue'; ...@@ -12,8 +13,9 @@ import Actions from './commit_sidebar/actions.vue';
export default { export default {
components: { components: {
DeprecatedModal, DeprecatedModal,
icon, Icon,
commitFilesList, CommitFilesList,
EmptyState,
Actions, Actions,
LoadingButton, LoadingButton,
CommitMessageField, CommitMessageField,
...@@ -32,33 +34,17 @@ export default { ...@@ -32,33 +34,17 @@ export default {
}, },
}, },
computed: { computed: {
...mapState([ ...mapState(['changedFiles', 'stagedFiles', 'rightPanelCollapsed']),
'currentProjectId',
'currentBranchId',
'rightPanelCollapsed',
'lastCommitMsg',
'changedFiles',
]),
...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled', 'branchName']), ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled', 'branchName']),
statusSvg() {
return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath;
},
}, },
methods: { methods: {
...mapActions(['setPanelCollapsedStatus']),
...mapActions('commit', [ ...mapActions('commit', [
'updateCommitMessage', 'updateCommitMessage',
'discardDraft', 'discardDraft',
'commitChanges', 'commitChanges',
'updateCommitAction', 'updateCommitAction',
]), ]),
toggleCollapsed() {
this.setPanelCollapsedStatus({
side: 'right',
collapsed: !this.rightPanelCollapsed,
});
},
forceCreateNewBranch() { forceCreateNewBranch() {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
}, },
...@@ -69,9 +55,6 @@ export default { ...@@ -69,9 +55,6 @@ export default {
<template> <template>
<div <div
class="multi-file-commit-panel-section" class="multi-file-commit-panel-section"
:class="{
'multi-file-commit-empty-state-container': !changedFiles.length
}"
> >
<deprecated-modal <deprecated-modal
id="ide-create-branch-modal" id="ide-create-branch-modal"
...@@ -85,15 +68,27 @@ export default { ...@@ -85,15 +68,27 @@ export default {
Would you like to create a new branch?`) }} Would you like to create a new branch?`) }}
</template> </template>
</deprecated-modal> </deprecated-modal>
<commit-files-list
title="Staged"
:file-list="changedFiles"
:collapsed="rightPanelCollapsed"
@toggleCollapsed="toggleCollapsed"
/>
<template <template
v-if="changedFiles.length" v-if="changedFiles.length || stagedFiles.length"
> >
<commit-files-list
icon-name="unstaged"
:title="__('Unstaged')"
:file-list="changedFiles"
action="stageAllChanges"
:action-btn-text="__('Stage all')"
item-action-component="stage-button"
/>
<commit-files-list
icon-name="staged"
:title="__('Staged')"
:file-list="stagedFiles"
action="unstageAllChanges"
:action-btn-text="__('Unstage all')"
item-action-component="unstage-button"
:show-toggle="false"
:staged-list="true"
/>
<form <form
class="multi-file-commit-form" class="multi-file-commit-form"
@submit.prevent.stop="commitChanges" @submit.prevent.stop="commitChanges"
...@@ -123,38 +118,10 @@ export default { ...@@ -123,38 +118,10 @@ export default {
</div> </div>
</form> </form>
</template> </template>
<div <empty-state
v-else-if="!rightPanelCollapsed" v-else
class="row js-empty-state" :no-changes-state-svg-path="noChangesStateSvgPath"
> :committed-state-svg-path="committedStateSvgPath"
<div class="col-10 col-offset-1"> />
<div class="svg-content svg-80">
<img :src="statusSvg" />
</div>
</div>
<div class="col-10 col-offset-1">
<div
class="text-content text-center"
v-if="!lastCommitMsg"
>
<h4>
{{ __('No changes') }}
</h4>
<p>
{{ __('Edit files in the editor and commit changes here') }}
</p>
</div>
<div
class="text-content text-center"
v-else
>
<h4>
{{ __('All changes are committed') }}
</h4>
<p v-html="lastCommitMsg">
</p>
</div>
</div>
</div>
</div> </div>
</template> </template>
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
}, },
computed: { computed: {
...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']),
...mapGetters(['currentMergeRequest']), ...mapGetters(['currentMergeRequest', 'getStagedFile']),
shouldHideEditor() { shouldHideEditor() {
return this.file && this.file.binary && !this.file.content; return this.file && this.file.binary && !this.file.content;
}, },
...@@ -120,7 +120,12 @@ export default { ...@@ -120,7 +120,12 @@ export default {
setupEditor() { setupEditor() {
if (!this.file || !this.editor.instance) return; if (!this.file || !this.editor.instance) return;
this.model = this.editor.createModel(this.file); const head = this.getStagedFile(this.file.path);
this.model = this.editor.createModel(
this.file,
this.file.staged && this.file.key.indexOf('unstaged-') === 0 ? head : null,
);
if (this.viewer === 'mrdiff') { if (this.viewer === 'mrdiff') {
this.editor.attachMergeRequestModel(this.model); this.editor.attachMergeRequestModel(this.model);
......
...@@ -102,8 +102,11 @@ export default { ...@@ -102,8 +102,11 @@ export default {
v-if="file.mrChange" v-if="file.mrChange"
/> />
<changed-file-icon <changed-file-icon
v-if="file.changed || file.tempFile || file.staged"
:file="file" :file="file"
v-if="file.changed || file.tempFile" :show-tooltip="true"
:show-staged-icon="true"
class="prepend-top-5 pull-right"
/> />
</span> </span>
<new-dropdown <new-dropdown
......
...@@ -26,13 +26,16 @@ export default { ...@@ -26,13 +26,16 @@ export default {
}, },
computed: { computed: {
closeLabel() { closeLabel() {
if (this.tab.changed || this.tab.tempFile) { if (this.fileHasChanged) {
return `${this.tab.name} changed`; return `${this.tab.name} changed`;
} }
return `Close ${this.tab.name}`; return `Close ${this.tab.name}`;
}, },
showChangedIcon() { showChangedIcon() {
return this.tab.changed ? !this.tabMouseOver : false; return this.fileHasChanged ? !this.tabMouseOver : false;
},
fileHasChanged() {
return this.tab.changed || this.tab.tempFile || this.tab.staged;
}, },
}, },
...@@ -42,18 +45,18 @@ export default { ...@@ -42,18 +45,18 @@ export default {
this.updateDelayViewerUpdated(true); this.updateDelayViewerUpdated(true);
if (tab.pending) { if (tab.pending) {
this.openPendingTab(tab); this.openPendingTab({ file: tab, keyPrefix: tab.staged ? 'staged' : 'unstaged' });
} else { } else {
this.$router.push(`/project${tab.url}`); this.$router.push(`/project${tab.url}`);
} }
}, },
mouseOverTab() { mouseOverTab() {
if (this.tab.changed) { if (this.fileHasChanged) {
this.tabMouseOver = true; this.tabMouseOver = true;
} }
}, },
mouseOutTab() { mouseOutTab() {
if (this.tab.changed) { if (this.fileHasChanged) {
this.tabMouseOver = false; this.tabMouseOver = false;
} }
}, },
......
...@@ -3,15 +3,16 @@ import Disposable from './disposable'; ...@@ -3,15 +3,16 @@ import Disposable from './disposable';
import eventHub from '../../eventhub'; import eventHub from '../../eventhub';
export default class Model { export default class Model {
constructor(monaco, file) { constructor(monaco, file, head = null) {
this.monaco = monaco; this.monaco = monaco;
this.disposable = new Disposable(); this.disposable = new Disposable();
this.file = file; this.file = file;
this.head = head;
this.content = file.content !== '' ? file.content : file.raw; this.content = file.content !== '' ? file.content : file.raw;
this.disposable.add( this.disposable.add(
(this.originalModel = this.monaco.editor.createModel( (this.originalModel = this.monaco.editor.createModel(
this.file.raw, head ? head.content : this.file.raw,
undefined, undefined,
new this.monaco.Uri(null, null, `original/${this.file.key}`), new this.monaco.Uri(null, null, `original/${this.file.key}`),
)), )),
...@@ -31,13 +32,15 @@ export default class Model { ...@@ -31,13 +32,15 @@ export default class Model {
); );
} }
this.events = new Map(); this.events = new Set();
this.updateContent = this.updateContent.bind(this); this.updateContent = this.updateContent.bind(this);
this.updateNewContent = this.updateNewContent.bind(this);
this.dispose = this.dispose.bind(this); this.dispose = this.dispose.bind(this);
eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose); eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent); eventHub.$on(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$on(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
} }
get url() { get url() {
...@@ -73,22 +76,36 @@ export default class Model { ...@@ -73,22 +76,36 @@ export default class Model {
} }
onChange(cb) { onChange(cb) {
this.events.set( this.events.add(this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))));
this.path, }
this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))),
); onDispose(cb) {
this.events.add(cb);
} }
updateContent(content) { updateContent({ content, changed }) {
this.getOriginalModel().setValue(content); this.getOriginalModel().setValue(content);
if (!changed) {
this.getModel().setValue(content);
}
}
updateNewContent(content) {
this.getModel().setValue(content); this.getModel().setValue(content);
} }
dispose() { dispose() {
this.disposable.dispose(); this.disposable.dispose();
this.events.forEach(cb => {
if (typeof cb === 'function') cb();
});
this.events.clear(); this.events.clear();
eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent); eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
} }
} }
...@@ -17,12 +17,12 @@ export default class ModelManager { ...@@ -17,12 +17,12 @@ export default class ModelManager {
return this.models.get(key); return this.models.get(key);
} }
addModel(file) { addModel(file, head = null) {
if (this.hasCachedModel(file.key)) { if (this.hasCachedModel(file.key)) {
return this.getModel(file.key); return this.getModel(file.key);
} }
const model = new Model(this.monaco, file); const model = new Model(this.monaco, file, head);
this.models.set(model.path, model); this.models.set(model.path, model);
this.disposable.add(model); this.disposable.add(model);
......
...@@ -38,6 +38,15 @@ export default class DecorationsController { ...@@ -38,6 +38,15 @@ export default class DecorationsController {
); );
} }
hasDecorations(model) {
return this.decorations.has(model.url);
}
removeDecorations(model) {
this.decorations.delete(model.url);
this.editorDecorations.delete(model.url);
}
dispose() { dispose() {
this.decorations.clear(); this.decorations.clear();
this.editorDecorations.clear(); this.editorDecorations.clear();
......
...@@ -3,7 +3,7 @@ import { throttle } from 'underscore'; ...@@ -3,7 +3,7 @@ import { throttle } from 'underscore';
import DirtyDiffWorker from './diff_worker'; import DirtyDiffWorker from './diff_worker';
import Disposable from '../common/disposable'; import Disposable from '../common/disposable';
export const getDiffChangeType = (change) => { export const getDiffChangeType = change => {
if (change.modified) { if (change.modified) {
return 'modified'; return 'modified';
} else if (change.added) { } else if (change.added) {
...@@ -16,12 +16,7 @@ export const getDiffChangeType = (change) => { ...@@ -16,12 +16,7 @@ export const getDiffChangeType = (change) => {
}; };
export const getDecorator = change => ({ export const getDecorator = change => ({
range: new monaco.Range( range: new monaco.Range(change.lineNumber, 1, change.endLineNumber, 1),
change.lineNumber,
1,
change.endLineNumber,
1,
),
options: { options: {
isWholeLine: true, isWholeLine: true,
linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`, linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
...@@ -31,6 +26,7 @@ export const getDecorator = change => ({ ...@@ -31,6 +26,7 @@ export const getDecorator = change => ({
export default class DirtyDiffController { export default class DirtyDiffController {
constructor(modelManager, decorationsController) { constructor(modelManager, decorationsController) {
this.disposable = new Disposable(); this.disposable = new Disposable();
this.models = new Map();
this.editorSimpleWorker = null; this.editorSimpleWorker = null;
this.modelManager = modelManager; this.modelManager = modelManager;
this.decorationsController = decorationsController; this.decorationsController = decorationsController;
...@@ -42,7 +38,15 @@ export default class DirtyDiffController { ...@@ -42,7 +38,15 @@ export default class DirtyDiffController {
} }
attachModel(model) { attachModel(model) {
if (this.models.has(model.url)) return;
model.onChange(() => this.throttledComputeDiff(model)); model.onChange(() => this.throttledComputeDiff(model));
model.onDispose(() => {
this.decorationsController.removeDecorations(model);
this.models.delete(model.url);
});
this.models.set(model.url, model);
} }
computeDiff(model) { computeDiff(model) {
...@@ -54,7 +58,11 @@ export default class DirtyDiffController { ...@@ -54,7 +58,11 @@ export default class DirtyDiffController {
} }
reDecorate(model) { reDecorate(model) {
this.decorationsController.decorate(model); if (this.decorationsController.hasDecorations(model)) {
this.decorationsController.decorate(model);
} else {
this.computeDiff(model);
}
} }
decorate({ data }) { decorate({ data }) {
...@@ -65,6 +73,7 @@ export default class DirtyDiffController { ...@@ -65,6 +73,7 @@ export default class DirtyDiffController {
dispose() { dispose() {
this.disposable.dispose(); this.disposable.dispose();
this.models.clear();
this.dirtyDiffWorker.removeEventListener('message', this.decorate); this.dirtyDiffWorker.removeEventListener('message', this.decorate);
this.dirtyDiffWorker.terminate(); this.dirtyDiffWorker.terminate();
......
...@@ -77,8 +77,8 @@ export default class Editor { ...@@ -77,8 +77,8 @@ export default class Editor {
} }
} }
createModel(file) { createModel(file, head = null) {
return this.modelManager.addModel(file); return this.modelManager.addModel(file, head);
} }
attachModel(model) { attachModel(model) {
......
import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash'; import flash from '~/flash';
...@@ -32,6 +33,22 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { ...@@ -32,6 +33,22 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
} }
}; };
export const toggleRightPanelCollapsed = (
{ dispatch, state },
e = undefined,
) => {
if (e) {
$(e.currentTarget)
.tooltip('hide')
.blur();
}
dispatch('setPanelCollapsedStatus', {
side: 'right',
collapsed: !state.rightPanelCollapsed,
});
};
export const setResizingStatus = ({ commit }, resizing) => { export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing); commit(types.SET_RESIZING_STATUS, resizing);
}; };
...@@ -104,6 +121,14 @@ export const scrollToTab = () => { ...@@ -104,6 +121,14 @@ export const scrollToTab = () => {
}); });
}; };
export const stageAllChanges = ({ state, commit }) => {
state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path));
};
export const unstageAllChanges = ({ state, commit }) => {
state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path));
};
export const updateViewer = ({ commit }, viewer) => { export const updateViewer = ({ commit }, viewer) => {
commit(types.UPDATE_VIEWER, viewer); commit(types.UPDATE_VIEWER, viewer);
}; };
......
...@@ -24,7 +24,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => { ...@@ -24,7 +24,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => {
if (nextFileToOpen.pending) { if (nextFileToOpen.pending) {
dispatch('updateViewer', 'diff'); dispatch('updateViewer', 'diff');
dispatch('openPendingTab', nextFileToOpen); dispatch('openPendingTab', {
file: nextFileToOpen,
keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged',
});
} else { } else {
dispatch('updateDelayViewerUpdated', true); dispatch('updateDelayViewerUpdated', true);
router.push(`/project${nextFileToOpen.url}`); router.push(`/project${nextFileToOpen.url}`);
...@@ -153,7 +156,7 @@ export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { ...@@ -153,7 +156,7 @@ export const setFileViewMode = ({ state, commit }, { file, viewMode }) => {
commit(types.SET_FILE_VIEWMODE, { file, viewMode }); commit(types.SET_FILE_VIEWMODE, { file, viewMode });
}; };
export const discardFileChanges = ({ state, commit }, path) => { export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => {
const file = state.entries[path]; const file = state.entries[path];
commit(types.DISCARD_FILE_CHANGES, path); commit(types.DISCARD_FILE_CHANGES, path);
...@@ -161,17 +164,40 @@ export const discardFileChanges = ({ state, commit }, path) => { ...@@ -161,17 +164,40 @@ export const discardFileChanges = ({ state, commit }, path) => {
if (file.tempFile && file.opened) { if (file.tempFile && file.opened) {
commit(types.TOGGLE_FILE_OPEN, path); commit(types.TOGGLE_FILE_OPEN, path);
} else if (getters.activeFile && file.path === getters.activeFile.path) {
dispatch('updateDelayViewerUpdated', true)
.then(() => {
router.push(`/project${file.url}`);
})
.catch(e => {
throw e;
});
}
eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.content);
eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content);
};
export const stageChange = ({ commit, state }, path) => {
const stagedFile = state.stagedFiles.find(f => f.path === path);
commit(types.STAGE_CHANGE, path);
if (stagedFile) {
eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content);
} }
};
eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); export const unstageChange = ({ commit }, path) => {
commit(types.UNSTAGE_CHANGE, path);
}; };
export const openPendingTab = ({ commit, getters, dispatch, state }, file) => { export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => {
if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') { if (getters.activeFile && getters.activeFile === file && state.viewer === 'diff') {
return false; return false;
} }
commit(types.ADD_PENDING_TAB, { file }); commit(types.ADD_PENDING_TAB, { file, keyPrefix });
dispatch('scrollToTab'); dispatch('scrollToTab');
......
import { __ } from '~/locale';
export const activeFile = state => state.openFiles.find(file => file.active) || null; export const activeFile = state => state.openFiles.find(file => file.active) || null;
export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); export const addedFiles = state => state.changedFiles.filter(f => f.tempFile);
...@@ -29,9 +31,15 @@ export const currentMergeRequest = state => { ...@@ -29,9 +31,15 @@ export const currentMergeRequest = state => {
}; };
// eslint-disable-next-line no-confusing-arrow // eslint-disable-next-line no-confusing-arrow
export const currentIcon = state => export const collapseButtonIcon = state =>
state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
export const hasChanges = state => !!state.changedFiles.length; export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length;
// eslint-disable-next-line no-confusing-arrow
export const collapseButtonTooltip = state =>
state.rightPanelCollapsed ? __('Expand sidebar') : __('Collapse sidebar');
export const hasMergeRequest = state => !!state.currentMergeRequestId; export const hasMergeRequest = state => !!state.currentMergeRequestId;
export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
...@@ -98,40 +98,25 @@ export const updateFilesAfterCommit = ( ...@@ -98,40 +98,25 @@ export const updateFilesAfterCommit = (
{ root: true }, { root: true },
); );
rootState.changedFiles.forEach(entry => { rootState.stagedFiles.forEach(file => {
commit( const changedFile = rootState.changedFiles.find(f => f.path === file.path);
rootTypes.SET_LAST_COMMIT_DATA,
{
entry,
lastCommit,
},
{ root: true },
);
eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content);
commit( commit(
rootTypes.SET_FILE_RAW_DATA, rootTypes.UPDATE_FILE_AFTER_COMMIT,
{ {
file: entry, file,
raw: entry.content, lastCommit,
}, },
{ root: true }, { root: true },
); );
commit( eventHub.$emit(`editor.update.model.content.${file.key}`, {
rootTypes.TOGGLE_FILE_CHANGED, content: file.content,
{ changed: !!changedFile,
file: entry, });
changed: false,
},
{ root: true },
);
}); });
commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true }); if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH && rootGetters.activeFile) {
if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
router.push( router.push(
`/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`, `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`,
); );
...@@ -184,6 +169,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState }) = ...@@ -184,6 +169,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState }) =
{ root: true }, { root: true },
); );
} }
commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
}) })
.then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)); .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH));
}) })
......
import * as consts from './constants'; import * as consts from './constants';
export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; const BRANCH_SUFFIX_COUNT = 5;
export const discardDraftButtonDisabled = state =>
state.commitMessage === '' || state.submitCommitLoading;
export const commitButtonDisabled = (state, getters, rootState) => export const commitButtonDisabled = (state, getters, rootState) =>
getters.discardDraftButtonDisabled || !rootState.changedFiles.length; getters.discardDraftButtonDisabled || !rootState.stagedFiles.length;
export const newBranchName = (state, _, rootState) => export const newBranchName = (state, _, rootState) =>
`${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`; `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(
-BRANCH_SUFFIX_COUNT,
)}`;
export const branchName = (state, getters, rootState) => { export const branchName = (state, getters, rootState) => {
if ( if (
......
...@@ -51,5 +51,10 @@ export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE'; ...@@ -51,5 +51,10 @@ export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE';
export const UPDATE_VIEWER = 'UPDATE_VIEWER'; export const UPDATE_VIEWER = 'UPDATE_VIEWER';
export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES';
export const STAGE_CHANGE = 'STAGE_CHANGE';
export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
...@@ -49,6 +49,11 @@ export default { ...@@ -49,6 +49,11 @@ export default {
lastCommitMsg, lastCommitMsg,
}); });
}, },
[types.CLEAR_STAGED_CHANGES](state) {
Object.assign(state, {
stagedFiles: [],
});
},
[types.SET_ENTRIES](state, entries) { [types.SET_ENTRIES](state, entries) {
Object.assign(state, { Object.assign(state, {
entries, entries,
...@@ -95,6 +100,22 @@ export default { ...@@ -95,6 +100,22 @@ export default {
delayViewerUpdated, delayViewerUpdated,
}); });
}, },
[types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) {
const changedFile = state.changedFiles.find(f => f.path === file.path);
Object.assign(state.entries[file.path], {
raw: file.content,
changed: !!changedFile,
staged: false,
lastCommit: Object.assign(state.entries[file.path].lastCommit, {
id: lastCommit.commit.id,
url: lastCommit.commit_path,
message: lastCommit.commit.message,
author: lastCommit.commit.author_name,
updatedAt: lastCommit.commit.authored_date,
}),
});
},
...projectMutations, ...projectMutations,
...mergeRequestMutation, ...mergeRequestMutation,
...fileMutations, ...fileMutations,
......
...@@ -57,7 +57,9 @@ export default { ...@@ -57,7 +57,9 @@ export default {
}); });
}, },
[types.UPDATE_FILE_CONTENT](state, { path, content }) { [types.UPDATE_FILE_CONTENT](state, { path, content }) {
const changed = content !== state.entries[path].raw; const stagedFile = state.stagedFiles.find(f => f.path === path);
const rawContent = stagedFile ? stagedFile.content : state.entries[path].raw;
const changed = content !== rawContent;
Object.assign(state.entries[path], { Object.assign(state.entries[path], {
content, content,
...@@ -91,8 +93,10 @@ export default { ...@@ -91,8 +93,10 @@ export default {
}); });
}, },
[types.DISCARD_FILE_CHANGES](state, path) { [types.DISCARD_FILE_CHANGES](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
Object.assign(state.entries[path], { Object.assign(state.entries[path], {
content: state.entries[path].raw, content: stagedFile ? stagedFile.content : state.entries[path].raw,
changed: false, changed: false,
}); });
}, },
...@@ -106,16 +110,67 @@ export default { ...@@ -106,16 +110,67 @@ export default {
changedFiles: state.changedFiles.filter(f => f.path !== path), changedFiles: state.changedFiles.filter(f => f.path !== path),
}); });
}, },
[types.STAGE_CHANGE](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
Object.assign(state, {
changedFiles: state.changedFiles.filter(f => f.path !== path),
entries: Object.assign(state.entries, {
[path]: Object.assign(state.entries[path], {
staged: true,
changed: false,
}),
}),
});
if (stagedFile) {
Object.assign(stagedFile, {
...state.entries[path],
});
} else {
Object.assign(state, {
stagedFiles: state.stagedFiles.concat({
...state.entries[path],
}),
});
}
},
[types.UNSTAGE_CHANGE](state, path) {
const changedFile = state.changedFiles.find(f => f.path === path);
const stagedFile = state.stagedFiles.find(f => f.path === path);
if (!changedFile && stagedFile) {
Object.assign(state.entries[path], {
...stagedFile,
key: state.entries[path].key,
active: state.entries[path].active,
opened: state.entries[path].opened,
changed: true,
});
Object.assign(state, {
changedFiles: state.changedFiles.concat(state.entries[path]),
});
}
Object.assign(state, {
stagedFiles: state.stagedFiles.filter(f => f.path !== path),
entries: Object.assign(state.entries, {
[path]: Object.assign(state.entries[path], {
staged: false,
}),
}),
});
},
[types.TOGGLE_FILE_CHANGED](state, { file, changed }) { [types.TOGGLE_FILE_CHANGED](state, { file, changed }) {
Object.assign(state.entries[file.path], { Object.assign(state.entries[file.path], {
changed, changed,
}); });
}, },
[types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) {
const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending); const key = `${keyPrefix}-${file.key}`;
let openFiles = state.openFiles.map(f => const pendingTab = state.openFiles.find(f => f.key === key && f.pending);
Object.assign(f, { active: f.path === file.path, opened: false }), let openFiles = state.openFiles.map(f => Object.assign(f, { active: false, opened: false }));
);
if (!pendingTab) { if (!pendingTab) {
const openFile = openFiles.find(f => f.path === file.path); const openFile = openFiles.find(f => f.path === file.path);
...@@ -126,10 +181,11 @@ export default { ...@@ -126,10 +181,11 @@ export default {
if (f.path === file.path) { if (f.path === file.path) {
return acc.concat({ return acc.concat({
...f, ...f,
content: file.content,
active: true, active: true,
pending: true, pending: true,
opened: true, opened: true,
key: `${keyPrefix}-${f.key}`, key,
}); });
} }
......
...@@ -3,6 +3,7 @@ export default () => ({ ...@@ -3,6 +3,7 @@ export default () => ({
currentBranchId: '', currentBranchId: '',
currentMergeRequestId: '', currentMergeRequestId: '',
changedFiles: [], changedFiles: [],
stagedFiles: [],
endpoints: {}, endpoints: {},
lastCommitMsg: '', lastCommitMsg: '',
lastCommitPath: '', lastCommitPath: '',
......
...@@ -15,6 +15,7 @@ export const dataStructure = () => ({ ...@@ -15,6 +15,7 @@ export const dataStructure = () => ({
opened: false, opened: false,
active: false, active: false,
changed: false, changed: false,
staged: false,
lastCommitPath: '', lastCommitPath: '',
lastCommit: { lastCommit: {
id: '', id: '',
...@@ -101,7 +102,7 @@ export const setPageTitle = title => { ...@@ -101,7 +102,7 @@ export const setPageTitle = title => {
export const createCommitPayload = (branch, newBranch, state, rootState) => ({ export const createCommitPayload = (branch, newBranch, state, rootState) => ({
branch, branch,
commit_message: state.commitMessage, commit_message: state.commitMessage,
actions: rootState.changedFiles.map(f => ({ actions: rootState.stagedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update', action: f.tempFile ? 'create' : 'update',
file_path: f.path, file_path: f.path,
content: f.content, content: f.content,
......
...@@ -30,10 +30,10 @@ export default class IssuableContext { ...@@ -30,10 +30,10 @@ export default class IssuableContext {
const $selectbox = $block.find('.selectbox'); const $selectbox = $block.find('.selectbox');
if ($selectbox.is(':visible')) { if ($selectbox.is(':visible')) {
$selectbox.hide(); $selectbox.hide();
$block.find('.value').show(); $block.find('.value:not(.dont-hide)').show();
} else { } else {
$selectbox.show(); $selectbox.show();
$block.find('.value').hide(); $block.find('.value:not(.dont-hide)').hide();
} }
if ($selectbox.is(':visible')) { if ($selectbox.is(':visible')) {
......
<script> <script>
import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import callout from '../../vue_shared/components/callout.vue';
export default { export default {
name: 'JobHeaderSection', name: 'JobHeaderSection',
components: { components: {
ciHeader, ciHeader,
loadingIcon, loadingIcon,
callout,
},
props: {
job: {
type: Object,
required: true,
}, },
props: { isLoading: {
job: { type: Boolean,
type: Object, required: true,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
}, },
data() { },
return { data() {
actions: this.getActions(), return {
}; actions: this.getActions(),
};
},
computed: {
status() {
return this.job && this.job.status;
}, },
computed: { shouldRenderContent() {
status() { return !this.isLoading && Object.keys(this.job).length;
return this.job && this.job.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length;
},
/**
* When job has not started the key will be `false`
* When job started the key will be a string with a date.
*/
jobStarted() {
return !this.job.started === false;
},
}, },
watch: { shouldRenderReason() {
job() { return !!(this.job.status && this.job.callout_message);
this.actions = this.getActions();
},
}, },
methods: { /**
getActions() { * When job has not started the key will be `false`
const actions = []; * When job started the key will be a string with a date.
*/
jobStarted() {
return !this.job.started === false;
},
},
watch: {
job() {
this.actions = this.getActions();
},
},
methods: {
getActions() {
const actions = [];
if (this.job.new_issue_path) { if (this.job.new_issue_path) {
actions.push({ actions.push({
label: 'New issue', label: 'New issue',
path: this.job.new_issue_path, path: this.job.new_issue_path,
cssClass: 'js-new-issue btn btn-new btn-inverted d-block d-lg-none d-xl-none', cssClass: 'js-new-issue btn btn-new btn-inverted d-block d-lg-none d-xl-none',
type: 'link', type: 'link',
}); });
} }
return actions; return actions;
},
}, },
}; },
};
</script> </script>
<template> <template>
<div class="js-build-header build-header top-area"> <header>
<ci-header <div class="js-build-header build-header top-area">
v-if="shouldRenderContent" <ci-header
:status="status" v-if="shouldRenderContent"
item-name="Job" :status="status"
:item-id="job.id" item-name="Job"
:time="job.created_at" :item-id="job.id"
:user="job.user" :time="job.created_at"
:actions="actions" :user="job.user"
:has-sidebar-button="true" :actions="actions"
:should-render-triggered-label="jobStarted" :has-sidebar-button="true"
/> :should-render-triggered-label="jobStarted"
<loading-icon />
v-if="isLoading" <loading-icon
size="2" v-if="isLoading"
class="prepend-top-default append-bottom-default" size="2"
class="prepend-top-default append-bottom-default"
/>
</div>
<callout
v-if="shouldRenderReason"
:message="job.callout_message"
/> />
</div> </header>
</template> </template>
<script> <script>
import detailRow from './sidebar_detail_row.vue'; import detailRow from './sidebar_detail_row.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago'; import timeagoMixin from '../../vue_shared/mixins/timeago';
import { timeIntervalInWords } from '../../lib/utils/datetime_utility'; import { timeIntervalInWords } from '../../lib/utils/datetime_utility';
export default { export default {
name: 'SidebarDetailsBlock', name: 'SidebarDetailsBlock',
components: { components: {
detailRow, detailRow,
loadingIcon, loadingIcon,
},
mixins: [timeagoMixin],
props: {
job: {
type: Object,
required: true,
}, },
mixins: [ isLoading: {
timeagoMixin, type: Boolean,
], required: true,
props: {
job: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
runnerHelpUrl: {
type: String,
required: false,
default: '',
},
}, },
computed: { canUserRetry: {
shouldRenderContent() { type: Boolean,
return !this.isLoading && Object.keys(this.job).length > 0; required: false,
}, default: false,
coverage() { },
return `${this.job.coverage}%`; runnerHelpUrl: {
}, type: String,
duration() { required: false,
return timeIntervalInWords(this.job.duration); default: '',
}, },
queued() { },
return timeIntervalInWords(this.job.queued); computed: {
}, shouldRenderContent() {
runnerId() { return !this.isLoading && Object.keys(this.job).length > 0;
return `#${this.job.runner.id}`; },
}, coverage() {
hasTimeout() { return `${this.job.coverage}%`;
return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; },
}, duration() {
timeout() { return timeIntervalInWords(this.job.duration);
if (this.job.metadata == null) { },
return ''; queued() {
} return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `#${this.job.runner.id}`;
},
retryButtonClass() {
let className = 'js-retry-button pull-right btn btn-retry visible-md-block visible-lg-block';
className +=
this.job.status && this.job.recoverable
? ' btn-primary'
: ' btn-inverted-secondary';
return className;
},
hasTimeout() {
return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null;
},
timeout() {
if (this.job.metadata == null) {
return '';
}
let t = this.job.metadata.timeout_human_readable; let t = this.job.metadata.timeout_human_readable;
if (this.job.metadata.timeout_source !== '') { if (this.job.metadata.timeout_source !== '') {
t += ` (from ${this.job.metadata.timeout_source})`; t += ` (from ${this.job.metadata.timeout_source})`;
} }
return t; return t;
},
renderBlock() {
return this.job.merge_request ||
this.job.duration ||
this.job.finished_data ||
this.job.erased_at ||
this.job.queued ||
this.job.runner ||
this.job.coverage ||
this.job.tags.length ||
this.job.cancel_path;
},
}, },
}; renderBlock() {
return (
this.job.merge_request ||
this.job.duration ||
this.job.finished_data ||
this.job.erased_at ||
this.job.queued ||
this.job.runner ||
this.job.coverage ||
this.job.tags.length ||
this.job.cancel_path
);
},
},
};
</script> </script>
<template> <template>
<div> <div>
<div class="block">
<strong class="inline prepend-top-8">
{{ job.name }}
</strong>
<a
v-if="canUserRetry"
:class="retryButtonClass"
:href="job.retry_path"
data-method="post"
rel="nofollow"
>
{{ __('Retry') }}
</a>
<button
type="button"
:aria-label="__('Toggle Sidebar')"
class="btn btn-blank gutter-toggle pull-right
visible-xs-block visible-sm-block js-sidebar-build-toggle"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-angle-double-right"
></i>
</button>
</div>
<template v-if="shouldRenderContent"> <template v-if="shouldRenderContent">
<div <div
class="block retry-link" class="block retry-link"
...@@ -85,16 +124,16 @@ ...@@ -85,16 +124,16 @@
class="js-new-issue btn btn-new btn-inverted" class="js-new-issue btn btn-new btn-inverted"
:href="job.new_issue_path" :href="job.new_issue_path"
> >
New issue {{ __('New issue') }}
</a> </a>
<a <a
v-if="job.retry_path" v-if="canUserRetry"
class="js-retry-job btn btn-inverted-secondary" class="js-retry-job btn btn-inverted-secondary"
:href="job.retry_path" :href="job.retry_path"
data-method="post" data-method="post"
rel="nofollow" rel="nofollow"
> >
Retry {{ __('Retry') }}
</a> </a>
</div> </div>
<div :class="{block : renderBlock }"> <div :class="{block : renderBlock }">
...@@ -103,7 +142,7 @@ ...@@ -103,7 +142,7 @@
v-if="job.merge_request" v-if="job.merge_request"
> >
<span class="build-light-text"> <span class="build-light-text">
Merge Request: {{ __('Merge Request:') }}
</span> </span>
<a :href="job.merge_request.path"> <a :href="job.merge_request.path">
!{{ job.merge_request.iid }} !{{ job.merge_request.iid }}
...@@ -158,7 +197,7 @@ ...@@ -158,7 +197,7 @@
v-if="job.tags.length" v-if="job.tags.length"
> >
<span class="build-light-text"> <span class="build-light-text">
Tags: {{ __('Tags:') }}
</span> </span>
<span <span
v-for="(tag, i) in job.tags" v-for="(tag, i) in job.tags"
...@@ -178,7 +217,7 @@ ...@@ -178,7 +217,7 @@
data-method="post" data-method="post"
rel="nofollow" rel="nofollow"
> >
Cancel {{ __('Cancel') }}
</a> </a>
</div> </div>
</div> </div>
......
...@@ -35,9 +35,11 @@ export default () => { ...@@ -35,9 +35,11 @@ export default () => {
}); });
// Sidebar information block // Sidebar information block
const detailsBlockElement = document.getElementById('js-details-block-vue');
const detailsBlockDataset = detailsBlockElement.dataset;
// eslint-disable-next-line // eslint-disable-next-line
new Vue({ new Vue({
el: '#js-details-block-vue', el: detailsBlockElement,
components: { components: {
detailsBlock, detailsBlock,
}, },
...@@ -50,6 +52,7 @@ export default () => { ...@@ -50,6 +52,7 @@ export default () => {
return createElement('details-block', { return createElement('details-block', {
props: { props: {
isLoading: this.mediator.state.isLoading, isLoading: this.mediator.state.isLoading,
canUserRetry: !!('canUserRetry' in detailsBlockDataset),
job: this.mediator.store.state.job, job: this.mediator.store.state.job,
runnerHelpUrl: dataset.runnerHelpUrl, runnerHelpUrl: dataset.runnerHelpUrl,
}, },
......
...@@ -83,7 +83,7 @@ export default class LabelsSelect { ...@@ -83,7 +83,7 @@ export default class LabelsSelect {
$dropdown.trigger('loading.gl.dropdown'); $dropdown.trigger('loading.gl.dropdown');
axios.put(issueUpdateURL, data) axios.put(issueUpdateURL, data)
.then(({ data }) => { .then(({ data }) => {
var labelCount, template, labelTooltipTitle, labelTitles; var labelCount, template, labelTooltipTitle, labelTitles, formattedLabels;
$loading.fadeOut(); $loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown'); $dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide(); $selectbox.hide();
...@@ -115,8 +115,7 @@ export default class LabelsSelect { ...@@ -115,8 +115,7 @@ export default class LabelsSelect {
labelTooltipTitle = labelTitles.join(', '); labelTooltipTitle = labelTitles.join(', ');
} }
else { else {
labelTooltipTitle = ''; labelTooltipTitle = __('Labels');
$sidebarLabelTooltip.tooltip('destroy');
} }
$sidebarLabelTooltip $sidebarLabelTooltip
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
import { __ } from '~/locale';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import { timeFor } from './lib/utils/datetime_utility'; import { timeFor } from './lib/utils/datetime_utility';
import ModalStore from './boards/stores/modal_store'; import ModalStore from './boards/stores/modal_store';
...@@ -25,7 +26,7 @@ export default class MilestoneSelect { ...@@ -25,7 +26,7 @@ export default class MilestoneSelect {
} }
$els.each((i, dropdown) => { $els.each((i, dropdown) => {
let collapsedSidebarLabelTemplate, milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault; let milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault;
const $dropdown = $(dropdown); const $dropdown = $(dropdown);
const projectId = $dropdown.data('projectId'); const projectId = $dropdown.data('projectId');
const milestonesUrl = $dropdown.data('milestones'); const milestonesUrl = $dropdown.data('milestones');
...@@ -52,7 +53,6 @@ export default class MilestoneSelect { ...@@ -52,7 +53,6 @@ export default class MilestoneSelect {
if (issueUpdateURL) { if (issueUpdateURL) {
milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>'); milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>');
milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- name %><br /><%- remaining %>" data-placement="left" data-html="true"> <%- title %> </span>');
} }
return $dropdown.glDropdown({ return $dropdown.glDropdown({
showMenuAbove: showMenuAbove, showMenuAbove: showMenuAbove,
...@@ -214,10 +214,16 @@ export default class MilestoneSelect { ...@@ -214,10 +214,16 @@ export default class MilestoneSelect {
data.milestone.remaining = timeFor(data.milestone.due_date); data.milestone.remaining = timeFor(data.milestone.due_date);
data.milestone.name = data.milestone.title; data.milestone.name = data.milestone.title;
$value.html(milestoneLinkTemplate(data.milestone)); $value.html(milestoneLinkTemplate(data.milestone));
return $sidebarCollapsedValue.find('span').html(collapsedSidebarLabelTemplate(data.milestone)); return $sidebarCollapsedValue
.attr('data-original-title', `${data.milestone.name}<br />${data.milestone.remaining}`)
.find('span')
.text(data.milestone.title);
} else { } else {
$value.html(milestoneLinkNoneTemplate); $value.html(milestoneLinkNoneTemplate);
return $sidebarCollapsedValue.find('span').text('No'); return $sidebarCollapsedValue
.attr('data-original-title', __('Milestone'))
.find('span')
.text(__('None'));
} }
}) })
.catch(() => { .catch(() => {
......
...@@ -19,7 +19,7 @@ function getSystemDate(systemUtcOffsetSeconds) { ...@@ -19,7 +19,7 @@ function getSystemDate(systemUtcOffsetSeconds) {
const date = new Date(); const date = new Date();
const localUtcOffsetMinutes = 0 - date.getTimezoneOffset(); const localUtcOffsetMinutes = 0 - date.getTimezoneOffset();
const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60; const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60;
date.setMinutes((date.getMinutes() - localUtcOffsetMinutes) + systemUtcOffsetMinutes); date.setMinutes(date.getMinutes() - localUtcOffsetMinutes + systemUtcOffsetMinutes);
return date; return date;
} }
...@@ -35,18 +35,36 @@ function formatTooltipText({ date, count }) { ...@@ -35,18 +35,36 @@ function formatTooltipText({ date, count }) {
return `${contribText}<br />${dateDayName} ${dateText}`; return `${contribText}<br />${dateDayName} ${dateText}`;
} }
const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]); const initColorKey = () =>
d3
.scaleLinear()
.range(['#acd5f2', '#254e77'])
.domain([0, 3]);
export default class ActivityCalendar { export default class ActivityCalendar {
constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) { constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0, firstDayOfWeek = 0) {
this.calendarActivitiesPath = calendarActivitiesPath; this.calendarActivitiesPath = calendarActivitiesPath;
this.clickDay = this.clickDay.bind(this); this.clickDay = this.clickDay.bind(this);
this.currentSelectedDate = ''; this.currentSelectedDate = '';
this.daySpace = 1; this.daySpace = 1;
this.daySize = 15; this.daySize = 15;
this.daySizeWithSpace = this.daySize + (this.daySpace * 2); this.daySizeWithSpace = this.daySize + this.daySpace * 2;
this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; this.monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
this.months = []; this.months = [];
this.firstDayOfWeek = firstDayOfWeek;
// Loop through the timestamps to create a group of objects // Loop through the timestamps to create a group of objects
// The group of objects will be grouped based on the day of the week they are // The group of objects will be grouped based on the day of the week they are
...@@ -70,7 +88,7 @@ export default class ActivityCalendar { ...@@ -70,7 +88,7 @@ export default class ActivityCalendar {
// Create a new group array if this is the first day of the week // Create a new group array if this is the first day of the week
// or if is first object // or if is first object
if ((day === 0 && i !== 0) || i === 0) { if ((day === this.firstDayOfWeek && i !== 0) || i === 0) {
this.timestampsTmp.push([]); this.timestampsTmp.push([]);
group += 1; group += 1;
} }
...@@ -109,21 +127,30 @@ export default class ActivityCalendar { ...@@ -109,21 +127,30 @@ export default class ActivityCalendar {
} }
renderSvg(container, group) { renderSvg(container, group) {
const width = ((group + 1) * this.daySizeWithSpace) + this.getExtraWidthPadding(group); const width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group);
return d3.select(container) return d3
.select(container)
.append('svg') .append('svg')
.attr('width', width) .attr('width', width)
.attr('height', 167) .attr('height', 167)
.attr('class', 'contrib-calendar'); .attr('class', 'contrib-calendar');
}
dayYPos(day) {
return this.daySizeWithSpace * ((day + 7 - this.firstDayOfWeek) % 7);
} }
renderDays() { renderDays() {
this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g') this.svg
.selectAll('g')
.data(this.timestampsTmp)
.enter()
.append('g')
.attr('transform', (group, i) => { .attr('transform', (group, i) => {
_.each(group, (stamp, a) => { _.each(group, (stamp, a) => {
if (a === 0 && stamp.day === 0) { if (a === 0 && stamp.day === 0) {
const month = stamp.date.getMonth(); const month = stamp.date.getMonth();
const x = (this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace; const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace;
const lastMonth = _.last(this.months); const lastMonth = _.last(this.months);
if ( if (
lastMonth == null || lastMonth == null ||
...@@ -133,86 +160,113 @@ export default class ActivityCalendar { ...@@ -133,86 +160,113 @@ export default class ActivityCalendar {
} }
} }
}); });
return `translate(${(this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace}, 18)`; return `translate(${this.daySizeWithSpace * i + 1 + this.daySizeWithSpace}, 18)`;
}) })
.selectAll('rect') .selectAll('rect')
.data(stamp => stamp) .data(stamp => stamp)
.enter() .enter()
.append('rect') .append('rect')
.attr('x', '0') .attr('x', '0')
.attr('y', stamp => this.daySizeWithSpace * stamp.day) .attr('y', stamp => this.dayYPos(stamp.day))
.attr('width', this.daySize) .attr('width', this.daySize)
.attr('height', this.daySize) .attr('height', this.daySize)
.attr('fill', stamp => ( .attr(
stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed' 'fill',
)) stamp => (stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed'),
.attr('title', stamp => formatTooltipText(stamp)) )
.attr('class', 'user-contrib-cell js-tooltip') .attr('title', stamp => formatTooltipText(stamp))
.attr('data-container', 'body') .attr('class', 'user-contrib-cell js-tooltip')
.on('click', this.clickDay); .attr('data-container', 'body')
.on('click', this.clickDay);
} }
renderDayTitles() { renderDayTitles() {
const days = [ const days = [
{ {
text: 'M', text: 'M',
y: 29 + (this.daySizeWithSpace * 1), y: 29 + this.dayYPos(1),
}, { },
{
text: 'W', text: 'W',
y: 29 + (this.daySizeWithSpace * 3), y: 29 + this.dayYPos(2),
}, { },
{
text: 'F', text: 'F',
y: 29 + (this.daySizeWithSpace * 5), y: 29 + this.dayYPos(3),
}, },
]; ];
this.svg.append('g') this.svg
.append('g')
.selectAll('text') .selectAll('text')
.data(days) .data(days)
.enter() .enter()
.append('text') .append('text')
.attr('text-anchor', 'middle') .attr('text-anchor', 'middle')
.attr('x', 8) .attr('x', 8)
.attr('y', day => day.y) .attr('y', day => day.y)
.text(day => day.text) .text(day => day.text)
.attr('class', 'user-contrib-text'); .attr('class', 'user-contrib-text');
} }
renderMonths() { renderMonths() {
this.svg.append('g') this.svg
.append('g')
.attr('direction', 'ltr') .attr('direction', 'ltr')
.selectAll('text') .selectAll('text')
.data(this.months) .data(this.months)
.enter() .enter()
.append('text') .append('text')
.attr('x', date => date.x) .attr('x', date => date.x)
.attr('y', 10) .attr('y', 10)
.attr('class', 'user-contrib-text') .attr('class', 'user-contrib-text')
.text(date => this.monthNames[date.month]); .text(date => this.monthNames[date.month]);
} }
renderKey() { renderKey() {
const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions']; const keyValues = [
const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; 'no contributions',
'1-9 contributions',
'10-19 contributions',
'20-29 contributions',
'30+ contributions',
];
const keyColors = [
'#ededed',
this.colorKey(0),
this.colorKey(1),
this.colorKey(2),
this.colorKey(3),
];
this.svg.append('g') this.svg
.attr('transform', `translate(18, ${(this.daySizeWithSpace * 8) + 16})`) .append('g')
.attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`)
.selectAll('rect') .selectAll('rect')
.data(keyColors) .data(keyColors)
.enter() .enter()
.append('rect') .append('rect')
.attr('width', this.daySize) .attr('width', this.daySize)
.attr('height', this.daySize) .attr('height', this.daySize)
.attr('x', (color, i) => this.daySizeWithSpace * i) .attr('x', (color, i) => this.daySizeWithSpace * i)
.attr('y', 0) .attr('y', 0)
.attr('fill', color => color) .attr('fill', color => color)
.attr('class', 'js-tooltip') .attr('class', 'js-tooltip')
.attr('title', (color, i) => keyValues[i]) .attr('title', (color, i) => keyValues[i])
.attr('data-container', 'body'); .attr('data-container', 'body');
} }
initColor() { initColor() {
const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; const colorRange = [
return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange); '#ededed',
this.colorKey(0),
this.colorKey(1),
this.colorKey(2),
this.colorKey(3),
];
return d3
.scaleThreshold()
.domain([0, 10, 20, 30])
.range(colorRange);
} }
clickDay(stamp) { clickDay(stamp) {
...@@ -227,14 +281,15 @@ export default class ActivityCalendar { ...@@ -227,14 +281,15 @@ export default class ActivityCalendar {
$('.user-calendar-activities').html(LOADING_HTML); $('.user-calendar-activities').html(LOADING_HTML);
axios.get(this.calendarActivitiesPath, { axios
params: { .get(this.calendarActivitiesPath, {
date, params: {
}, date,
responseType: 'text', },
}) responseType: 'text',
.then(({ data }) => $('.user-calendar-activities').html(data)) })
.catch(() => flash(__('An error occurred while retrieving calendar activity'))); .then(({ data }) => $('.user-calendar-activities').html(data))
.catch(() => flash(__('An error occurred while retrieving calendar activity')));
} else { } else {
this.currentSelectedDate = ''; this.currentSelectedDate = '';
$('.user-calendar-activities').html(''); $('.user-calendar-activities').html('');
......
...@@ -5,6 +5,7 @@ import _ from 'underscore'; ...@@ -5,6 +5,7 @@ import _ from 'underscore';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import flash from './flash'; import flash from './flash';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import { __ } from './locale';
function Sidebar(currentUser) { function Sidebar(currentUser) {
this.toggleTodo = this.toggleTodo.bind(this); this.toggleTodo = this.toggleTodo.bind(this);
...@@ -41,12 +42,14 @@ Sidebar.prototype.addEventListeners = function() { ...@@ -41,12 +42,14 @@ Sidebar.prototype.addEventListeners = function() {
}; };
Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
var $allGutterToggleIcons, $this, $thisIcon; var $allGutterToggleIcons, $this, isExpanded, tooltipLabel;
e.preventDefault(); e.preventDefault();
$this = $(this); $this = $(this);
$thisIcon = $this.find('i'); isExpanded = $this.find('i').hasClass('fa-angle-double-right');
tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar');
$allGutterToggleIcons = $('.js-sidebar-toggle i'); $allGutterToggleIcons = $('.js-sidebar-toggle i');
if ($thisIcon.hasClass('fa-angle-double-right')) {
if (isExpanded) {
$allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left');
$('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
$('.layout-page').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); $('.layout-page').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
...@@ -57,6 +60,9 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { ...@@ -57,6 +60,9 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
if (gl.lazyLoader) gl.lazyLoader.loadCheck(); if (gl.lazyLoader) gl.lazyLoader.loadCheck();
} }
$this.attr('data-original-title', tooltipLabel);
if (!triggered) { if (!triggered) {
Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed')); Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed'));
} }
......
<script> <script>
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
export default { export default {
name: 'Assignees', name: 'Assignees',
directives: {
tooltip,
},
props: { props: {
rootPath: { rootPath: {
type: String, type: String,
...@@ -14,6 +20,11 @@ export default { ...@@ -14,6 +20,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
issuableType: {
type: String,
require: true,
default: 'issue',
},
}, },
data() { data() {
return { return {
...@@ -62,6 +73,12 @@ export default { ...@@ -62,6 +73,12 @@ export default {
names.push(`+ ${this.users.length - maxRender} more`); names.push(`+ ${this.users.length - maxRender} more`);
} }
if (!this.users.length) {
const emptyTooltipLabel = this.issuableType === 'issue' ?
__('Assignee(s)') : __('Assignee');
names.push(emptyTooltipLabel);
}
return names.join(', '); return names.join(', ');
}, },
sidebarAvatarCounter() { sidebarAvatarCounter() {
...@@ -109,7 +126,8 @@ export default { ...@@ -109,7 +126,8 @@ export default {
<div> <div>
<div <div
class="sidebar-collapsed-icon sidebar-collapsed-user" class="sidebar-collapsed-icon sidebar-collapsed-user"
:class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" :class="{ 'multiple-users': hasMoreThanOneAssignee }"
v-tooltip
data-container="body" data-container="body"
data-placement="left" data-placement="left"
:title="collapsedTooltipTitle" :title="collapsedTooltipTitle"
......
<script> <script>
import Flash from '../../../flash'; import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
import AssigneeTitle from './assignee_title.vue'; import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue'; import Assignees from './assignees.vue';
import Store from '../../stores/sidebar_store';
import eventHub from '../../event_hub';
export default { export default {
name: 'SidebarAssignees', name: 'SidebarAssignees',
...@@ -25,6 +25,11 @@ export default { ...@@ -25,6 +25,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
issuableType: {
type: String,
require: true,
default: 'issue',
},
}, },
data() { data() {
return { return {
...@@ -90,6 +95,7 @@ export default { ...@@ -90,6 +95,7 @@ export default {
:users="store.assignees" :users="store.assignees"
:editable="store.editable" :editable="store.editable"
@assign-self="assignSelf" @assign-self="assignSelf"
:issuable-type="issuableType"
/> />
</div> </div>
</template> </template>
<script> <script>
import Flash from '../../../flash'; import { __ } from '~/locale';
import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue'; import editForm from './edit_form.vue';
import Icon from '../../../vue_shared/components/icon.vue';
import { __ } from '../../../locale';
import eventHub from '../../event_hub';
export default { export default {
components: { components: {
editForm, editForm,
Icon, Icon,
}, },
directives: {
tooltip,
},
props: { props: {
isConfidential: { isConfidential: {
required: true, required: true,
...@@ -33,6 +37,9 @@ export default { ...@@ -33,6 +37,9 @@ export default {
confidentialityIcon() { confidentialityIcon() {
return this.isConfidential ? 'eye-slash' : 'eye'; return this.isConfidential ? 'eye-slash' : 'eye';
}, },
tooltipLabel() {
return this.isConfidential ? __('Confidential') : __('Not confidential');
},
}, },
created() { created() {
eventHub.$on('closeConfidentialityForm', this.toggleForm); eventHub.$on('closeConfidentialityForm', this.toggleForm);
...@@ -65,6 +72,10 @@ export default { ...@@ -65,6 +72,10 @@ export default {
<div <div
class="sidebar-collapsed-icon" class="sidebar-collapsed-icon"
@click="toggleForm" @click="toggleForm"
v-tooltip
data-container="body"
data-placement="left"
:title="tooltipLabel"
> >
<icon <icon
:name="confidentialityIcon" :name="confidentialityIcon"
......
<script> <script>
import { __ } from '~/locale';
import Flash from '~/flash'; import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
import issuableMixin from '~/vue_shared/mixins/issuable';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue'; import editForm from './edit_form.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
import Icon from '../../../vue_shared/components/icon.vue';
import eventHub from '../../event_hub';
export default { export default {
components: { components: {
editForm, editForm,
Icon, Icon,
}, },
directives: {
tooltip,
},
mixins: [issuableMixin], mixins: [issuableMixin],
props: { props: {
...@@ -44,6 +51,10 @@ export default { ...@@ -44,6 +51,10 @@ export default {
isLockDialogOpen() { isLockDialogOpen() {
return this.mediator.store.isLockDialogOpen; return this.mediator.store.isLockDialogOpen;
}, },
tooltipLabel() {
return this.isLocked ? __('Locked') : __('Unlocked');
},
}, },
created() { created() {
...@@ -85,6 +96,10 @@ export default { ...@@ -85,6 +96,10 @@ export default {
<div <div
class="sidebar-collapsed-icon" class="sidebar-collapsed-icon"
@click="toggleForm" @click="toggleForm"
v-tooltip
data-container="body"
data-placement="left"
:title="tooltipLabel"
> >
<icon <icon
:name="lockIcon" :name="lockIcon"
......
<script> <script>
import { __, n__, sprintf } from '../../../locale'; import { __, n__, sprintf } from '~/locale';
import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; import tooltip from '~/vue_shared/directives/tooltip';
import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
export default { export default {
directives: {
tooltip,
},
components: { components: {
loadingIcon, loadingIcon,
userAvatarImage, userAvatarImage,
...@@ -72,7 +76,13 @@ ...@@ -72,7 +76,13 @@
<template> <template>
<div> <div>
<div class="sidebar-collapsed-icon"> <div
class="sidebar-collapsed-icon"
v-tooltip
data-container="body"
data-placement="left"
:title="participantLabel"
>
<i <i
class="fa fa-users" class="fa fa-users"
aria-hidden="true" aria-hidden="true"
......
<script> <script>
import icon from '../../../vue_shared/components/icon.vue'; import { __, sprintf } from '~/locale';
import { abbreviateTime } from '../../../lib/utils/pretty_time'; import { abbreviateTime } from '~/lib/utils/pretty_time';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default { export default {
name: 'TimeTrackingCollapsedState', name: 'TimeTrackingCollapsedState',
components: { components: {
icon, icon,
}, },
directives: {
tooltip,
},
props: { props: {
showComparisonState: { showComparisonState: {
type: Boolean, type: Boolean,
...@@ -79,6 +84,21 @@ ...@@ -79,6 +84,21 @@
return ''; return '';
}, },
timeTrackedTooltipText() {
let title;
if (this.showComparisonState) {
title = __('Time remaining');
} else if (this.showEstimateOnlyState) {
title = __('Estimated');
} else if (this.showSpentOnlyState) {
title = __('Time spent');
}
return sprintf('%{title}: %{text}', ({ title, text: this.text }));
},
tooltipText() {
return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText;
},
}, },
methods: { methods: {
abbreviateTime(timeStr) { abbreviateTime(timeStr) {
...@@ -89,7 +109,13 @@ ...@@ -89,7 +109,13 @@
</script> </script>
<template> <template>
<div class="sidebar-collapsed-icon"> <div
class="sidebar-collapsed-icon"
v-tooltip
data-container="body"
data-placement="left"
:title="tooltipText"
>
<icon name="timer" /> <icon name="timer" />
<div class="time-tracking-collapsed-summary"> <div class="time-tracking-collapsed-summary">
<div :class="divClass"> <div :class="divClass">
......
...@@ -27,6 +27,7 @@ function mountAssigneesComponent(mediator) { ...@@ -27,6 +27,7 @@ function mountAssigneesComponent(mediator) {
mediator, mediator,
field: el.dataset.field, field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'), signedIn: el.hasAttribute('data-signed-in'),
issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
}, },
}), }),
}); });
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import { __ } from './locale';
import ModalStore from './boards/stores/modal_store'; import ModalStore from './boards/stores/modal_store';
// TODO: remove eventHub hack after code splitting refactor // TODO: remove eventHub hack after code splitting refactor
...@@ -182,7 +183,7 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -182,7 +183,7 @@ function UsersSelect(currentUser, els, options = {}) {
return axios.put(issueURL, data) return axios.put(issueURL, data)
.then(({ data }) => { .then(({ data }) => {
var user; var user, tooltipTitle;
$dropdown.trigger('loaded.gl.dropdown'); $dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut(); $loading.fadeOut();
if (data.assignee) { if (data.assignee) {
...@@ -191,15 +192,17 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -191,15 +192,17 @@ function UsersSelect(currentUser, els, options = {}) {
username: data.assignee.username, username: data.assignee.username,
avatar: data.assignee.avatar_url avatar: data.assignee.avatar_url
}; };
tooltipTitle = _.escape(user.name);
} else { } else {
user = { user = {
name: 'Unassigned', name: 'Unassigned',
username: '', username: '',
avatar: '' avatar: ''
}; };
tooltipTitle = __('Assignee');
} }
$value.html(assigneeTemplate(user)); $value.html(assigneeTemplate(user));
$collapsedSidebar.attr('title', _.escape(user.name)).tooltip('_fixTitle'); $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle');
return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
}); });
}; };
......
<script>
const calloutVariants = ['danger', 'success', 'info', 'warning'];
export default {
props: {
category: {
type: String,
required: false,
default: calloutVariants[0],
validator: value => calloutVariants.includes(value),
},
message: {
type: String,
required: true,
},
},
};
</script>
<template>
<div
:class="`bs-callout bs-callout-${category}`"
role="alert"
aria-live="assertive"
>
{{ message }}
</div>
</template>
<script> <script>
export default { import { __ } from '~/locale';
name: 'ToggleSidebar', import tooltip from '~/vue_shared/directives/tooltip';
props: {
collapsed: { export default {
type: Boolean, name: 'ToggleSidebar',
required: true, directives: {
}, tooltip,
},
props: {
collapsed: {
type: Boolean,
required: true,
},
},
computed: {
tooltipLabel() {
return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar');
}, },
methods: { },
toggle() { methods: {
this.$emit('toggle'); toggle() {
}, this.$emit('toggle');
}, },
}; },
};
</script> </script>
<template> <template>
...@@ -20,6 +31,10 @@ ...@@ -20,6 +31,10 @@
type="button" type="button"
class="btn btn-blank gutter-toggle btn-sidebar-action" class="btn btn-blank gutter-toggle btn-sidebar-action"
@click="toggle" @click="toggle"
v-tooltip
data-container="body"
data-placement="left"
:title="tooltipLabel"
> >
<i <i
aria-label="toggle collapse" aria-label="toggle collapse"
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
.nav-header-btn { .nav-header-btn {
padding: 10px $gl-sidebar-padding; padding: 10px $gl-sidebar-padding;
color: inherit; color: inherit;
transition-duration: .3s; transition-duration: 0.3s;
position: absolute; position: absolute;
top: 0; top: 0;
cursor: pointer; cursor: pointer;
...@@ -137,6 +137,12 @@ ...@@ -137,6 +137,12 @@
} }
} }
.issuable-sidebar .labels {
.value.dont-hide ~ .selectbox {
padding-top: $gl-padding-8;
}
}
.pikaday-container { .pikaday-container {
.pika-single { .pika-single {
margin-top: 2px; margin-top: 2px;
...@@ -151,4 +157,3 @@ ...@@ -151,4 +157,3 @@
.sidebar-collapsed-icon .sidebar-collapsed-value { .sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px; font-size: 12px;
} }
...@@ -247,6 +247,7 @@ $btn-sm-side-margin: 7px; ...@@ -247,6 +247,7 @@ $btn-sm-side-margin: 7px;
$btn-xs-side-margin: 5px; $btn-xs-side-margin: 5px;
$issue-status-expired: $orange-500; $issue-status-expired: $orange-500;
$issuable-sidebar-color: $gl-text-color-secondary; $issuable-sidebar-color: $gl-text-color-secondary;
$sidebar-block-hover-color: #ebebeb;
$group-path-color: #999; $group-path-color: #999;
$namespace-kind-color: #aaa; $namespace-kind-color: #aaa;
$panel-heading-link-color: #777; $panel-heading-link-color: #777;
...@@ -373,6 +374,8 @@ $dropdown-hover-color: $blue-400; ...@@ -373,6 +374,8 @@ $dropdown-hover-color: $blue-400;
$link-active-background: rgba(0, 0, 0, 0.04); $link-active-background: rgba(0, 0, 0, 0.04);
$link-hover-background: rgba(0, 0, 0, 0.06); $link-hover-background: rgba(0, 0, 0, 0.06);
$inactive-badge-background: rgba(0, 0, 0, 0.08); $inactive-badge-background: rgba(0, 0, 0, 0.08);
$sidebar-toggle-height: 60px;
$sidebar-milestone-toggle-bottom-margin: 10px;
/* /*
* Buttons * Buttons
......
@keyframes fade-out-status { @keyframes fade-out-status {
0%, 50% { opacity: 1; } 0%,
100% { opacity: 0; } 50% {
opacity: 1;
}
100% {
opacity: 0;
}
} }
@keyframes blinking-dots { @keyframes blinking-dots {
0% { 0% {
background-color: rgba($white-light, 1); background-color: rgba($white-light, 1);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
25% { 25% {
background-color: rgba($white-light, 0.4); background-color: rgba($white-light, 0.4);
box-shadow: 12px 0 0 0 rgba($white-light, 2), box-shadow: 12px 0 0 0 rgba($white-light, 2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
75% { 75% {
background-color: rgba($white-light, 0.4); background-color: rgba($white-light, 0.4);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 1); 24px 0 0 0 rgba($white-light, 1);
} }
100% { 100% {
background-color: rgba($white-light, 1); background-color: rgba($white-light, 1);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
} }
@keyframes blinking-scroll-button { @keyframes blinking-scroll-button {
0% { opacity: 0.2; } 0% {
25% { opacity: 0.5; } opacity: 0.2;
50% { opacity: 0.7; } }
100% { opacity: 1; }
25% {
opacity: 0.5;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
} }
.build-page { .build-page {
...@@ -125,12 +142,12 @@ ...@@ -125,12 +142,12 @@
.btn-scroll.animate { .btn-scroll.animate {
.first-triangle { .first-triangle {
animation: blinking-scroll-button 1s ease infinite; animation: blinking-scroll-button 1s ease infinite;
animation-delay: .3s; animation-delay: 0.3s;
} }
.second-triangle { .second-triangle {
animation: blinking-scroll-button 1s ease infinite; animation: blinking-scroll-button 1s ease infinite;
animation-delay: .2s; animation-delay: 0.2s;
} }
.third-triangle { .third-triangle {
......
...@@ -187,7 +187,12 @@ ...@@ -187,7 +187,12 @@
padding-left: 10px; padding-left: 10px;
&:hover { &:hover {
color: $gray-darkest; color: $gl-text-color;
}
&:hover,
&:focus {
text-decoration: none;
} }
} }
...@@ -368,6 +373,14 @@ ...@@ -368,6 +373,14 @@
padding: 15px 0 0; padding: 15px 0 0;
border-bottom: 0; border-bottom: 0;
overflow: hidden; overflow: hidden;
&:hover {
background-color: $sidebar-block-hover-color;
}
&.issuable-sidebar-header {
padding-top: 0;
}
} }
.participants { .participants {
...@@ -380,8 +393,17 @@ ...@@ -380,8 +393,17 @@
.gutter-toggle { .gutter-toggle {
width: 100%; width: 100%;
height: $sidebar-toggle-height;
margin-left: 0; margin-left: 0;
padding-left: 25px; padding-left: 0;
border-bottom: 1px solid $border-gray-dark;
}
a.gutter-toggle {
display: flex;
justify-content: center;
flex-direction: column;
text-align: center;
} }
.sidebar-collapsed-icon { .sidebar-collapsed-icon {
...@@ -428,10 +450,10 @@ ...@@ -428,10 +450,10 @@
.btn-clipboard { .btn-clipboard {
border: 0; border: 0;
background: transparent;
color: $issuable-sidebar-color; color: $issuable-sidebar-color;
&:hover { &:hover {
background: transparent;
color: $gl-text-color; color: $gl-text-color;
} }
} }
......
...@@ -53,10 +53,6 @@ ...@@ -53,10 +53,6 @@
} }
.milestone-sidebar { .milestone-sidebar {
.gutter-toggle {
margin-bottom: 10px;
}
.milestone-progress { .milestone-progress {
.title { .title {
padding-top: 5px; padding-top: 5px;
...@@ -102,7 +98,17 @@ ...@@ -102,7 +98,17 @@
margin-right: 0; margin-right: 0;
} }
.right-sidebar-expanded & {
.gutter-toggle {
margin-bottom: $sidebar-milestone-toggle-bottom-margin;
}
}
.right-sidebar-collapsed & { .right-sidebar-collapsed & {
.milestone-progress {
padding-top: 0;
}
.reference { .reference {
border-top: 1px solid $border-gray-normal; border-top: 1px solid $border-gray-normal;
} }
......
...@@ -68,6 +68,10 @@ ...@@ -68,6 +68,10 @@
.ide-file-changed-icon { .ide-file-changed-icon {
margin-left: auto; margin-left: auto;
> svg {
display: block;
}
} }
.ide-new-btn { .ide-new-btn {
...@@ -378,7 +382,11 @@ ...@@ -378,7 +382,11 @@
padding: $gl-bar-padding $gl-padding; padding: $gl-bar-padding $gl-padding;
background: $white-light; background: $white-light;
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
> div + div {
padding-left: $gl-padding;
}
svg { svg {
vertical-align: middle; vertical-align: middle;
...@@ -521,9 +529,13 @@ ...@@ -521,9 +529,13 @@
overflow: auto; overflow: auto;
} }
.multi-file-commit-empty-state-container { .ide-commit-empty-state {
align-items: center; padding: 0 $gl-padding;
justify-content: center; }
.ide-commit-empty-state-container {
margin-top: auto;
margin-bottom: auto;
} }
.multi-file-commit-panel-header { .multi-file-commit-panel-header {
...@@ -532,35 +544,22 @@ ...@@ -532,35 +544,22 @@
margin-bottom: 0; margin-bottom: 0;
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
padding: $gl-btn-padding 0; padding: $gl-btn-padding 0;
&.is-collapsed {
border-bottom: 1px solid $white-dark;
svg {
margin-left: auto;
margin-right: auto;
}
.multi-file-commit-panel-collapse-btn {
margin-right: auto;
margin-left: auto;
border-left: 0;
}
}
} }
.multi-file-commit-panel-header-title { .multi-file-commit-panel-header-title {
display: flex; display: flex;
flex: 1; flex: 1;
padding: 0 $gl-btn-padding; padding-left: $grid-size;
svg { svg {
margin-right: $gl-btn-padding; margin-right: $gl-btn-padding;
color: $theme-gray-700;
} }
} }
.multi-file-commit-panel-collapse-btn { .multi-file-commit-panel-collapse-btn {
border-left: 1px solid $white-dark; border-left: 1px solid $white-dark;
margin-left: auto;
} }
.multi-file-commit-list { .multi-file-commit-list {
...@@ -574,12 +573,14 @@ ...@@ -574,12 +573,14 @@
display: flex; display: flex;
padding: 0; padding: 0;
align-items: center; align-items: center;
border-radius: $border-radius-default;
.multi-file-discard-btn { .multi-file-discard-btn {
display: none; display: none;
margin-top: -2px;
margin-left: auto; margin-left: auto;
margin-right: $grid-size;
color: $gl-link-color; color: $gl-link-color;
padding: 0 2px;
&:focus, &:focus,
&:hover { &:hover {
...@@ -591,26 +592,31 @@ ...@@ -591,26 +592,31 @@
background: $white-normal; background: $white-normal;
.multi-file-discard-btn { .multi-file-discard-btn {
display: block; display: flex;
} }
} }
} }
.multi-file-addition { .multi-file-additions,
.multi-file-additions-solid {
fill: $green-500; fill: $green-500;
} }
.multi-file-modified { .multi-file-modified,
.multi-file-modified-solid {
fill: $orange-500; fill: $orange-500;
} }
.multi-file-commit-list-collapsed { .multi-file-commit-list-collapsed {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: $gl-padding 0;
> svg { svg {
display: block;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
color: $theme-gray-700;
} }
.file-status-icon { .file-status-icon {
...@@ -622,7 +628,7 @@ ...@@ -622,7 +628,7 @@
.multi-file-commit-list-path { .multi-file-commit-list-path {
padding: $grid-size / 2; padding: $grid-size / 2;
padding-left: $gl-padding; padding-left: $grid-size;
background: none; background: none;
border: 0; border: 0;
text-align: left; text-align: left;
...@@ -807,6 +813,41 @@ ...@@ -807,6 +813,41 @@
} }
} }
.ide-commit-list-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 16px;
&:not(.is-collapsed) {
flex: 1;
min-height: 140px;
}
&.is-collapsed {
.multi-file-commit-panel-header {
margin-left: -$gl-padding;
margin-right: -$gl-padding;
svg {
margin-left: auto;
margin-right: auto;
}
.multi-file-commit-panel-collapse-btn {
margin-right: auto;
margin-left: auto;
border-left: 0;
}
}
}
}
.ide-staged-action-btn {
margin-left: auto;
color: $gl-link-color;
}
.ide-commit-radios { .ide-commit-radios {
label { label {
font-weight: normal; font-weight: normal;
......
...@@ -78,6 +78,8 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -78,6 +78,8 @@ class Projects::JobsController < Projects::ApplicationController
result.merge!(trace.to_h) result.merge!(trace.to_h)
end end
result[:html] = result[:html].presence || 'No job log'
render json: result render json: result
end end
end end
......
...@@ -28,11 +28,12 @@ class Projects::RepositoriesController < Projects::ApplicationController ...@@ -28,11 +28,12 @@ class Projects::RepositoriesController < Projects::ApplicationController
end end
def assign_archive_vars def assign_archive_vars
@id = params[:id] if params[:id]
@ref, @filename = extract_ref(params[:id])
return unless @id else
@ref = params[:ref]
@ref, @filename = extract_ref(@id) @filename = nil
end
rescue InvalidPathError rescue InvalidPathError
render_404 render_404
end end
......
...@@ -29,8 +29,7 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -29,8 +29,7 @@ class Projects::WikisController < Projects::ApplicationController
else else
return render('empty') unless can?(current_user, :create_wiki, @project) return render('empty') unless can?(current_user, :create_wiki, @project)
@page = WikiPage.new(@project_wiki) @page = build_page(title: params[:id])
@page.title = params[:id]
render 'edit' render 'edit'
end end
...@@ -54,7 +53,7 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -54,7 +53,7 @@ class Projects::WikisController < Projects::ApplicationController
else else
render 'edit' render 'edit'
end end
rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e rescue WikiPage::PageChangedError, WikiPage::PageRenameError, Gitlab::Git::Wiki::OperationError => e
@error = e @error = e
render 'edit' render 'edit'
end end
...@@ -70,6 +69,11 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -70,6 +69,11 @@ class Projects::WikisController < Projects::ApplicationController
else else
render action: "edit" render action: "edit"
end end
rescue Gitlab::Git::Wiki::OperationError => e
@page = build_page(wiki_params)
@error = e
render 'edit'
end end
def history def history
...@@ -94,6 +98,9 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -94,6 +98,9 @@ class Projects::WikisController < Projects::ApplicationController
redirect_to project_wiki_path(@project, :home), redirect_to project_wiki_path(@project, :home),
status: 302, status: 302,
notice: "Page was successfully deleted" notice: "Page was successfully deleted"
rescue Gitlab::Git::Wiki::OperationError => e
@error = e
render 'edit'
end end
def git_access def git_access
...@@ -116,4 +123,10 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -116,4 +123,10 @@ class Projects::WikisController < Projects::ApplicationController
def wiki_params def wiki_params
params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha) params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha)
end end
def build_page(args)
WikiPage.new(@project_wiki).tap do |page|
page.update_attributes(args)
end
end
end end
...@@ -134,10 +134,8 @@ class GroupDescendantsFinder ...@@ -134,10 +134,8 @@ class GroupDescendantsFinder
end end
def direct_child_projects def direct_child_projects
GroupProjectsFinder.new(group: parent_group, GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
current_user: current_user, .execute
options: { only_owned: true },
params: params).execute
end end
# Finds all projects nested under `parent_group` or any of its descendant # Finds all projects nested under `parent_group` or any of its descendant
......
...@@ -9,6 +9,32 @@ module IssuablesHelper ...@@ -9,6 +9,32 @@ module IssuablesHelper
"right-sidebar-#{sidebar_gutter_collapsed? ? 'collapsed' : 'expanded'}" "right-sidebar-#{sidebar_gutter_collapsed? ? 'collapsed' : 'expanded'}"
end end
def sidebar_gutter_tooltip_text
sidebar_gutter_collapsed? ? _('Expand sidebar') : _('Collapse sidebar')
end
def sidebar_assignee_tooltip_label(issuable)
if issuable.assignee
issuable.assignee.name
else
issuable.allows_multiple_assignees? ? _('Assignee(s)') : _('Assignee')
end
end
def sidebar_due_date_tooltip_label(issuable)
if issuable.due_date
"#{_('Due date')}<br />#{due_date_remaining_days(issuable)}"
else
_('Due date')
end
end
def due_date_remaining_days(issuable)
remaining_days_in_words = remaining_days_in_words(issuable)
"#{issuable.due_date.to_s(:medium)} (#{remaining_days_in_words})"
end
def multi_label_name(current_labels, default_label) def multi_label_name(current_labels, default_label)
if current_labels && current_labels.any? if current_labels && current_labels.any?
title = current_labels.first.try(:title) title = current_labels.first.try(:title)
...@@ -153,10 +179,14 @@ module IssuablesHelper ...@@ -153,10 +179,14 @@ module IssuablesHelper
def issuable_labels_tooltip(labels, limit: 5) def issuable_labels_tooltip(labels, limit: 5)
first, last = labels.partition.with_index { |_, i| i < limit } first, last = labels.partition.with_index { |_, i| i < limit }
label_names = first.collect(&:name) if labels && labels.any?
label_names << "and #{last.size} more" unless last.empty? label_names = first.collect(&:name)
label_names << "and #{last.size} more" unless last.empty?
label_names.join(', ') label_names.join(', ')
else
_("Labels")
end
end end
def issuables_state_counter_text(issuable_type, state, display_count) def issuables_state_counter_text(issuable_type, state, display_count)
...@@ -321,7 +351,7 @@ module IssuablesHelper ...@@ -321,7 +351,7 @@ module IssuablesHelper
def issuable_todo_button_data(issuable, todo, is_collapsed) def issuable_todo_button_data(issuable, todo, is_collapsed)
{ {
todo_text: "Add todo", todo_text: "Add todo",
mark_text: "Mark done", mark_text: "Mark todo as done",
todo_icon: (is_collapsed ? icon('plus-square') : nil), todo_icon: (is_collapsed ? icon('plus-square') : nil),
mark_icon: (is_collapsed ? icon('check-square', class: 'todo-undone') : nil), mark_icon: (is_collapsed ? icon('check-square', class: 'todo-undone') : nil),
issuable_id: issuable.id, issuable_id: issuable.id,
......
module MilestonesHelper module MilestonesHelper
include EntityDateHelper
def milestones_filter_path(opts = {}) def milestones_filter_path(opts = {})
if @project if @project
project_milestones_path(@project, opts) project_milestones_path(@project, opts)
...@@ -72,6 +74,19 @@ module MilestonesHelper ...@@ -72,6 +74,19 @@ module MilestonesHelper
end end
end end
def milestone_progress_tooltip_text(milestone)
has_issues = milestone.total_issues_count(current_user) > 0
if has_issues
[
_('Progress'),
_("%{percent}%% complete") % { percent: milestone.percent_complete(current_user) }
].join('<br />')
else
_('Progress')
end
end
def milestone_progress_bar(milestone) def milestone_progress_bar(milestone)
options = { options = {
class: 'progress-bar bg-success', class: 'progress-bar bg-success',
...@@ -95,27 +110,69 @@ module MilestonesHelper ...@@ -95,27 +110,69 @@ module MilestonesHelper
end end
def milestone_tooltip_title(milestone) def milestone_tooltip_title(milestone)
if milestone.due_date if milestone
[milestone.due_date.to_s(:medium), "(#{milestone_remaining_days(milestone)})"].join(' ') "#{milestone.title}<br />#{milestone_tooltip_due_date(milestone)}"
else
_('Milestone')
end end
end end
def milestone_remaining_days(milestone) def milestone_time_for(date, date_type)
if milestone.expired? title = date_type == :start ? "Start date" : "End date"
content_tag(:strong, 'Past due')
elsif milestone.upcoming? if date
content_tag(:strong, 'Upcoming') time_ago = time_ago_in_words(date)
elsif milestone.due_date time_ago.slice!("about ")
time_ago = time_ago_in_words(milestone.due_date)
content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" } time_ago << if date.past?
content.slice!("about ") " ago"
content << " remaining" else
content.html_safe " remaining"
elsif milestone.start_date && milestone.start_date.past? end
days = milestone.elapsed_days
content = content_tag(:strong, days) content = [
content << " #{'day'.pluralize(days)} elapsed" title,
"<br />",
date.to_s(:medium),
"(#{time_ago})"
].join(" ")
content.html_safe content.html_safe
else
title
end
end
def milestone_issues_tooltip_text(milestone)
issues = milestone.count_issues_by_state(current_user)
return _("Issues") if issues.empty?
content = []
content << n_("1 open issue", "%d open issues", issues["opened"]) % issues["opened"] if issues["opened"]
content << n_("1 closed issue", "%d closed issues", issues["closed"]) % issues["closed"] if issues["closed"]
content.join('<br />').html_safe
end
def milestone_merge_requests_tooltip_text(milestone)
merge_requests = milestone.merge_requests
return _("Merge requests") if merge_requests.empty?
content = []
content << n_("1 open merge request", "%d open merge requests", merge_requests.opened.count) % merge_requests.opened.count if merge_requests.opened.any?
content << n_("1 closed merge request", "%d closed merge requests", merge_requests.closed.count) % merge_requests.closed.count if merge_requests.closed.any?
content << n_("1 merged merge request", "%d merged merge requests", merge_requests.merged.count) % merge_requests.merged.count if merge_requests.merged.any?
content.join('<br />').html_safe
end
def milestone_tooltip_due_date(milestone)
if milestone.due_date
"#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone)})"
end end
end end
......
...@@ -28,7 +28,7 @@ module NavHelper ...@@ -28,7 +28,7 @@ module NavHelper
end end
elsif current_path?('jobs#show') elsif current_path?('jobs#show')
%w[page-gutter build-sidebar right-sidebar-expanded] %w[page-gutter build-sidebar right-sidebar-expanded]
elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access') elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access', 'destroy')
%w[page-gutter wiki-sidebar right-sidebar-expanded] %w[page-gutter wiki-sidebar right-sidebar-expanded]
else else
[] []
......
...@@ -20,7 +20,7 @@ module Ci ...@@ -20,7 +20,7 @@ module Ci
has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment' has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment'
has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_sections, class_name: 'Ci::BuildTraceSection'
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent
has_one :job_artifacts_archive, -> { where(file_type: Ci::JobArtifact.file_types[:archive]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :job_artifacts_archive, -> { where(file_type: Ci::JobArtifact.file_types[:archive]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
has_one :job_artifacts_metadata, -> { where(file_type: Ci::JobArtifact.file_types[:metadata]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :job_artifacts_metadata, -> { where(file_type: Ci::JobArtifact.file_types[:metadata]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
has_one :job_artifacts_trace, -> { where(file_type: Ci::JobArtifact.file_types[:trace]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :job_artifacts_trace, -> { where(file_type: Ci::JobArtifact.file_types[:trace]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
...@@ -95,8 +95,8 @@ module Ci ...@@ -95,8 +95,8 @@ module Ci
run_after_commit { BuildHooksWorker.perform_async(build.id) } run_after_commit { BuildHooksWorker.perform_async(build.id) }
end end
after_commit :update_project_statistics_after_save, on: [:create, :update] after_save :update_project_statistics_after_save, if: :artifacts_size_changed?
after_commit :update_project_statistics, on: :destroy after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed?
class << self class << self
# This is needed for url_for to work, # This is needed for url_for to work,
...@@ -611,7 +611,7 @@ module Ci ...@@ -611,7 +611,7 @@ module Ci
Gitlab::Ci::Variables::Collection.new.tap do |variables| Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI', value: 'true') variables.append(key: 'CI', value: 'true')
variables.append(key: 'GITLAB_CI', value: 'true') variables.append(key: 'GITLAB_CI', value: 'true')
variables.append(key: 'GITLAB_FEATURES', value: project.namespace.features.join(',')) variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(','))
variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') variables.append(key: 'CI_SERVER_NAME', value: 'GitLab')
variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION) variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION)
...@@ -664,16 +664,20 @@ module Ci ...@@ -664,16 +664,20 @@ module Ci
pipeline.config_processor.build_attributes(name) pipeline.config_processor.build_attributes(name)
end end
def update_project_statistics def update_project_statistics_after_save
return unless project update_project_statistics(read_attribute(:artifacts_size).to_i - artifacts_size_was.to_i)
end
ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size]) def update_project_statistics_after_destroy
update_project_statistics(-artifacts_size)
end end
def update_project_statistics_after_save def update_project_statistics(difference)
if previous_changes.include?('artifacts_size') ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference)
update_project_statistics end
end
def project_destroyed?
project.pending_delete?
end end
end end
end end
...@@ -7,12 +7,15 @@ module Ci ...@@ -7,12 +7,15 @@ module Ci
belongs_to :project belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
before_save :update_file_store mount_uploader :file, JobArtifactUploader
before_save :set_size, if: :file_changed? before_save :set_size, if: :file_changed?
after_save :update_project_statistics_after_save, if: :size_changed?
after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed?
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } after_save :update_file_store
mount_uploader :file, JobArtifactUploader scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
delegate :exists?, :open, to: :file delegate :exists?, :open, to: :file
...@@ -23,7 +26,9 @@ module Ci ...@@ -23,7 +26,9 @@ module Ci
} }
def update_file_store def update_file_store
self.file_store = file.object_store # The file.object_store is set during `uploader.store!`
# which happens after object is inserted/updated
self.update_column(:file_store, file.object_store)
end end
def self.artifacts_size_for(project) def self.artifacts_size_for(project)
...@@ -34,10 +39,6 @@ module Ci ...@@ -34,10 +39,6 @@ module Ci
[nil, ::JobArtifactUploader::Store::LOCAL].include?(self.file_store) [nil, ::JobArtifactUploader::Store::LOCAL].include?(self.file_store)
end end
def set_size
self.size = file.size
end
def expire_in def expire_in
expire_at - Time.now if expire_at expire_at - Time.now if expire_at
end end
...@@ -48,5 +49,28 @@ module Ci ...@@ -48,5 +49,28 @@ module Ci
ChronicDuration.parse(value)&.seconds&.from_now ChronicDuration.parse(value)&.seconds&.from_now
end end
end end
private
def set_size
self.size = file.size
end
def update_project_statistics_after_save
update_project_statistics(size.to_i - size_was.to_i)
end
def update_project_statistics_after_destroy
update_project_statistics(-self.size)
end
def update_project_statistics(difference)
ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference)
end
def project_destroyed?
# Use job.project to avoid extra DB query for project
job.project.pending_delete?
end
end end
end end
...@@ -13,7 +13,7 @@ module Ci ...@@ -13,7 +13,7 @@ module Ci
has_many :builds has_many :builds
has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, -> { auto_include(false) }, through: :runner_projects has_many :projects, through: :runner_projects
has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build'
......
...@@ -15,7 +15,7 @@ module Clusters ...@@ -15,7 +15,7 @@ module Clusters
belongs_to :user belongs_to :user
has_many :cluster_projects, class_name: 'Clusters::Project' has_many :cluster_projects, class_name: 'Clusters::Project'
has_many :projects, -> { auto_include(false) }, through: :cluster_projects, class_name: '::Project' has_many :projects, through: :cluster_projects, class_name: '::Project'
# we force autosave to happen when we save `Cluster` model # we force autosave to happen when we save `Cluster` model
has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true
......
...@@ -248,7 +248,7 @@ class Commit ...@@ -248,7 +248,7 @@ class Commit
end end
def notes_with_associations def notes_with_associations
notes.includes(:author) notes.includes(:author, :award_emoji)
end end
def merge_requests def merge_requests
......
...@@ -37,7 +37,20 @@ module GroupDescendant ...@@ -37,7 +37,20 @@ module GroupDescendant
parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id } parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
if parent.nil? && !child.parent_id.nil? if parent.nil? && !child.parent_id.nil?
raise ArgumentError.new('parent was not preloaded') parent = child.parent
exception = ArgumentError.new <<~MSG
parent: [GroupDescendant: #{parent.inspect}] was not preloaded for [#{child.inspect}]")
This error is not user facing, but causes a +1 query.
MSG
extras = {
parent: parent,
child: child,
preloaded: preloaded.map(&:full_path)
}
issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785'
Gitlab::Sentry.track_exception(exception, issue_url: issue_url, extra: extras)
end end
if parent.nil? && hierarchy_top.present? if parent.nil? && hierarchy_top.present?
......
...@@ -48,7 +48,7 @@ module Issuable ...@@ -48,7 +48,7 @@ module Issuable
end end
has_many :label_links, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :label_links, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :labels, -> { auto_include(false) }, through: :label_links has_many :labels, through: :label_links
has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :metrics has_one :metrics
......
...@@ -102,14 +102,14 @@ module Milestoneish ...@@ -102,14 +102,14 @@ module Milestoneish
Gitlab::TimeTrackingFormatter.output(total_issue_time_estimate) Gitlab::TimeTrackingFormatter.output(total_issue_time_estimate)
end end
private
def count_issues_by_state(user) def count_issues_by_state(user)
memoize_per_user(user, :count_issues_by_state) do memoize_per_user(user, :count_issues_by_state) do
issues_visible_to_user(user).reorder(nil).group(:state).count issues_visible_to_user(user).reorder(nil).group(:state).count
end end
end end
private
def memoize_per_user(user, method_name) def memoize_per_user(user, method_name)
memoized_users[method_name][user&.id] ||= yield memoized_users[method_name][user&.id] ||= yield
end end
......
...@@ -102,7 +102,7 @@ module ResolvableDiscussion ...@@ -102,7 +102,7 @@ module ResolvableDiscussion
yield(notes_relation) yield(notes_relation)
# Set the notes array to the updated notes # Set the notes array to the updated notes
@notes = notes_relation.fresh.auto_include(false).to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables @notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables
self.class.memoized_values.each do |name| self.class.memoized_values.each do |name|
clear_memoization(name) clear_memoization(name)
......
...@@ -2,7 +2,7 @@ class DeployKey < Key ...@@ -2,7 +2,7 @@ class DeployKey < Key
include IgnorableColumn include IgnorableColumn
has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, -> { auto_include(false) }, through: :deploy_keys_projects has_many :projects, through: :deploy_keys_projects
scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) } scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) }
scope :are_public, -> { where(public: true) } scope :are_public, -> { where(public: true) }
......
...@@ -8,7 +8,7 @@ class DeployToken < ActiveRecord::Base ...@@ -8,7 +8,7 @@ class DeployToken < ActiveRecord::Base
default_value_for(:expires_at) { Forever.date } default_value_for(:expires_at) { Forever.date }
has_many :project_deploy_tokens, inverse_of: :deploy_token has_many :project_deploy_tokens, inverse_of: :deploy_token
has_many :projects, -> { auto_include(false) }, through: :project_deploy_tokens has_many :projects, through: :project_deploy_tokens
validate :ensure_at_least_one_scope validate :ensure_at_least_one_scope
before_save :ensure_token before_save :ensure_token
......
class ForkNetwork < ActiveRecord::Base class ForkNetwork < ActiveRecord::Base
belongs_to :root_project, class_name: 'Project' belongs_to :root_project, class_name: 'Project'
has_many :fork_network_members has_many :fork_network_members
has_many :projects, -> { auto_include(false) }, through: :fork_network_members has_many :projects, through: :fork_network_members
after_create :add_root_as_member, if: :root_project after_create :add_root_as_member, if: :root_project
......
...@@ -12,9 +12,9 @@ class Group < Namespace ...@@ -12,9 +12,9 @@ class Group < Namespace
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members alias_method :members, :group_members
has_many :users, -> { auto_include(false) }, through: :group_members has_many :users, through: :group_members
has_many :owners, has_many :owners,
-> { where(members: { access_level: Gitlab::Access::OWNER }).auto_include(false) }, -> { where(members: { access_level: Gitlab::Access::OWNER }) },
through: :group_members, through: :group_members,
source: :user source: :user
...@@ -23,7 +23,7 @@ class Group < Namespace ...@@ -23,7 +23,7 @@ class Group < Namespace
has_many :milestones has_many :milestones
has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :shared_projects, -> { auto_include(false) }, through: :project_group_links, source: :project has_many :shared_projects, through: :project_group_links, source: :project
has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
has_many :labels, class_name: 'GroupLabel' has_many :labels, class_name: 'GroupLabel'
has_many :variables, class_name: 'Ci::GroupVariable' has_many :variables, class_name: 'Ci::GroupVariable'
......
...@@ -34,7 +34,7 @@ class Issue < ActiveRecord::Base ...@@ -34,7 +34,7 @@ class Issue < ActiveRecord::Base
dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignees has_many :issue_assignees
has_many :assignees, -> { auto_include(false) }, class_name: "User", through: :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees
validates :project, presence: true validates :project, presence: true
......
...@@ -18,8 +18,8 @@ class Label < ActiveRecord::Base ...@@ -18,8 +18,8 @@ class Label < ActiveRecord::Base
has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :priorities, class_name: 'LabelPriority' has_many :priorities, class_name: 'LabelPriority'
has_many :label_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :label_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :issues, -> { auto_include(false) }, through: :label_links, source: :target, source_type: 'Issue' has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, -> { auto_include(false) }, through: :label_links, source: :target, source_type: 'MergeRequest' has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
before_validation :strip_whitespace_from_title_and_color before_validation :strip_whitespace_from_title_and_color
......
...@@ -3,7 +3,7 @@ class LfsObject < ActiveRecord::Base ...@@ -3,7 +3,7 @@ class LfsObject < ActiveRecord::Base
include ObjectStorage::BackgroundMove include ObjectStorage::BackgroundMove
has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, -> { auto_include(false) }, through: :lfs_objects_projects has_many :projects, through: :lfs_objects_projects
scope :with_files_stored_locally, -> { where(file_store: [nil, LfsObjectUploader::Store::LOCAL]) } scope :with_files_stored_locally, -> { where(file_store: [nil, LfsObjectUploader::Store::LOCAL]) }
...@@ -11,10 +11,12 @@ class LfsObject < ActiveRecord::Base ...@@ -11,10 +11,12 @@ class LfsObject < ActiveRecord::Base
mount_uploader :file, LfsObjectUploader mount_uploader :file, LfsObjectUploader
before_save :update_file_store after_save :update_file_store
def update_file_store def update_file_store
self.file_store = file.object_store # The file.object_store is set during `uploader.store!`
# which happens after object is inserted/updated
self.update_column(:file_store, file.object_store)
end end
def project_allowed_access?(project) def project_allowed_access?(project)
......
...@@ -22,7 +22,7 @@ class Milestone < ActiveRecord::Base ...@@ -22,7 +22,7 @@ class Milestone < ActiveRecord::Base
belongs_to :group belongs_to :group
has_many :issues has_many :issues
has_many :labels, -> { distinct.reorder('labels.title').auto_include(false) }, through: :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests has_many :merge_requests
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
......
...@@ -248,10 +248,6 @@ class Namespace < ActiveRecord::Base ...@@ -248,10 +248,6 @@ class Namespace < ActiveRecord::Base
all_projects.with_storage_feature(:repository).find_each(&:remove_exports) all_projects.with_storage_feature(:repository).find_each(&:remove_exports)
end end
def features
[]
end
def refresh_project_authorizations def refresh_project_authorizations
owner.refresh_authorized_projects owner.refresh_authorized_projects
end end
......
...@@ -138,11 +138,11 @@ class Project < ActiveRecord::Base ...@@ -138,11 +138,11 @@ class Project < ActiveRecord::Base
has_one :packagist_service has_one :packagist_service
# TODO: replace these relations with the fork network versions # TODO: replace these relations with the fork network versions
has_one :forked_project_link, foreign_key: "forked_to_project_id" has_one :forked_project_link, foreign_key: "forked_to_project_id"
has_one :forked_from_project, -> { auto_include(false) }, through: :forked_project_link has_one :forked_from_project, through: :forked_project_link
has_many :forked_project_links, foreign_key: "forked_from_project_id" has_many :forked_project_links, foreign_key: "forked_from_project_id"
has_many :forks, -> { auto_include(false) }, through: :forked_project_links, source: :forked_to_project has_many :forks, through: :forked_project_links, source: :forked_to_project
# TODO: replace these relations with the fork network versions # TODO: replace these relations with the fork network versions
has_one :root_of_fork_network, has_one :root_of_fork_network,
...@@ -150,7 +150,7 @@ class Project < ActiveRecord::Base ...@@ -150,7 +150,7 @@ class Project < ActiveRecord::Base
inverse_of: :root_project, inverse_of: :root_project,
class_name: 'ForkNetwork' class_name: 'ForkNetwork'
has_one :fork_network_member has_one :fork_network_member
has_one :fork_network, -> { auto_include(false) }, through: :fork_network_member has_one :fork_network, through: :fork_network_member
# Merge Requests for target project should be removed with it # Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id' has_many :merge_requests, foreign_key: 'target_project_id'
...@@ -167,27 +167,27 @@ class Project < ActiveRecord::Base ...@@ -167,27 +167,27 @@ class Project < ActiveRecord::Base
has_many :protected_tags has_many :protected_tags
has_many :project_authorizations has_many :project_authorizations
has_many :authorized_users, -> { auto_include(false) }, through: :project_authorizations, source: :user, class_name: 'User' has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
has_many :project_members, -> { where(requested_at: nil) }, has_many :project_members, -> { where(requested_at: nil) },
as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :project_members alias_method :members, :project_members
has_many :users, -> { auto_include(false) }, through: :project_members has_many :users, through: :project_members
has_many :requesters, -> { where.not(requested_at: nil) }, has_many :requesters, -> { where.not(requested_at: nil) },
as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :members_and_requesters, as: :source, class_name: 'ProjectMember' has_many :members_and_requesters, as: :source, class_name: 'ProjectMember'
has_many :deploy_keys_projects has_many :deploy_keys_projects
has_many :deploy_keys, -> { auto_include(false) }, through: :deploy_keys_projects has_many :deploy_keys, through: :deploy_keys_projects
has_many :users_star_projects has_many :users_star_projects
has_many :starrers, -> { auto_include(false) }, through: :users_star_projects, source: :user has_many :starrers, through: :users_star_projects, source: :user
has_many :releases has_many :releases
has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :lfs_objects, -> { auto_include(false) }, through: :lfs_objects_projects has_many :lfs_objects, through: :lfs_objects_projects
has_many :lfs_file_locks has_many :lfs_file_locks
has_many :project_group_links has_many :project_group_links
has_many :invited_groups, -> { auto_include(false) }, through: :project_group_links, source: :group has_many :invited_groups, through: :project_group_links, source: :group
has_many :pages_domains has_many :pages_domains
has_many :todos has_many :todos
has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
...@@ -199,7 +199,7 @@ class Project < ActiveRecord::Base ...@@ -199,7 +199,7 @@ class Project < ActiveRecord::Base
has_one :statistics, class_name: 'ProjectStatistics' has_one :statistics, class_name: 'ProjectStatistics'
has_one :cluster_project, class_name: 'Clusters::Project' has_one :cluster_project, class_name: 'Clusters::Project'
has_many :clusters, -> { auto_include(false) }, through: :cluster_project, class_name: 'Clusters::Cluster' has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
# Container repositories need to remove data from the container registry, # Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy # which is not managed by the DB. Hence we're still using dependent: :destroy
...@@ -216,16 +216,16 @@ class Project < ActiveRecord::Base ...@@ -216,16 +216,16 @@ class Project < ActiveRecord::Base
has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
has_many :runner_projects, class_name: 'Ci::RunnerProject' has_many :runner_projects, class_name: 'Ci::RunnerProject'
has_many :runners, -> { auto_include(false) }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_many :variables, class_name: 'Ci::Variable' has_many :variables, class_name: 'Ci::Variable'
has_many :triggers, class_name: 'Ci::Trigger' has_many :triggers, class_name: 'Ci::Trigger'
has_many :environments has_many :environments
has_many :deployments has_many :deployments
has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule'
has_many :project_deploy_tokens has_many :project_deploy_tokens
has_many :deploy_tokens, -> { auto_include(false) }, through: :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens
has_many :active_runners, -> { active.auto_include(false) }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_one :auto_devops, class_name: 'ProjectAutoDevops' has_one :auto_devops, class_name: 'ProjectAutoDevops'
has_many :custom_attributes, class_name: 'ProjectCustomAttribute' has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
...@@ -1875,6 +1875,10 @@ class Project < ActiveRecord::Base ...@@ -1875,6 +1875,10 @@ class Project < ActiveRecord::Base
memoized_results[cache_key] memoized_results[cache_key]
end end
def licensed_features
[]
end
private private
def storage def storage
......
...@@ -4,15 +4,15 @@ class ProjectStatistics < ActiveRecord::Base ...@@ -4,15 +4,15 @@ class ProjectStatistics < ActiveRecord::Base
before_save :update_storage_size before_save :update_storage_size
STORAGE_COLUMNS = [:repository_size, :lfs_objects_size, :build_artifacts_size].freeze COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze
STATISTICS_COLUMNS = [:commit_count] + STORAGE_COLUMNS INCREMENTABLE_COLUMNS = [:build_artifacts_size].freeze
def total_repository_size def total_repository_size
repository_size + lfs_objects_size repository_size + lfs_objects_size
end end
def refresh!(only: nil) def refresh!(only: nil)
STATISTICS_COLUMNS.each do |column, generator| COLUMNS_TO_REFRESH.each do |column, generator|
if only.blank? || only.include?(column) if only.blank? || only.include?(column)
public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend
end end
...@@ -34,13 +34,15 @@ class ProjectStatistics < ActiveRecord::Base ...@@ -34,13 +34,15 @@ class ProjectStatistics < ActiveRecord::Base
self.lfs_objects_size = project.lfs_objects.sum(:size) self.lfs_objects_size = project.lfs_objects.sum(:size)
end end
def update_build_artifacts_size def update_storage_size
self.build_artifacts_size = self.storage_size = repository_size + lfs_objects_size + build_artifacts_size
project.builds.sum(:artifacts_size) +
Ci::JobArtifact.artifacts_size_for(self.project)
end end
def update_storage_size def self.increment_statistic(project_id, key, amount)
self.storage_size = STORAGE_COLUMNS.sum(&method(:read_attribute)) raise ArgumentError, "Cannot increment attribute: #{key}" unless key.in?(INCREMENTABLE_COLUMNS)
return if amount == 0
where(project_id: project_id)
.update_all(["#{key} = COALESCE(#{key}, 0) + (?)", amount])
end end
end end
...@@ -179,7 +179,11 @@ class ProjectWiki ...@@ -179,7 +179,11 @@ class ProjectWiki
def commit_details(action, message = nil, title = nil) def commit_details(action, message = nil, title = nil)
commit_message = message || default_message(action, title) commit_message = message || default_message(action, title)
Gitlab::Git::Wiki::CommitDetails.new(@user.name, @user.email, commit_message) Gitlab::Git::Wiki::CommitDetails.new(@user.id,
@user.username,
@user.name,
@user.email,
commit_message)
end end
def default_message(action, title) def default_message(action, title)
......
...@@ -22,7 +22,7 @@ class Todo < ActiveRecord::Base ...@@ -22,7 +22,7 @@ class Todo < ActiveRecord::Base
belongs_to :author, class_name: "User" belongs_to :author, class_name: "User"
belongs_to :note belongs_to :note
belongs_to :project belongs_to :project
belongs_to :target, -> { auto_include(false) }, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true delegate :name, :email, to: :author, prefix: true, allow_nil: true
......
...@@ -96,23 +96,23 @@ class User < ActiveRecord::Base ...@@ -96,23 +96,23 @@ class User < ActiveRecord::Base
# Groups # Groups
has_many :members has_many :members
has_many :group_members, -> { where(requested_at: nil) }, source: 'GroupMember' has_many :group_members, -> { where(requested_at: nil) }, source: 'GroupMember'
has_many :groups, -> { auto_include(false) }, through: :group_members has_many :groups, through: :group_members
has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }).auto_include(false) }, through: :group_members, source: :group has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group
has_many :masters_groups, -> { where(members: { access_level: Gitlab::Access::MASTER }).auto_include(false) }, through: :group_members, source: :group has_many :masters_groups, -> { where(members: { access_level: Gitlab::Access::MASTER }) }, through: :group_members, source: :group
# Projects # Projects
has_many :groups_projects, -> { auto_include(false) }, through: :groups, source: :projects has_many :groups_projects, through: :groups, source: :projects
has_many :personal_projects, -> { auto_include(false) }, through: :namespace, source: :projects has_many :personal_projects, through: :namespace, source: :projects
has_many :project_members, -> { where(requested_at: nil) } has_many :project_members, -> { where(requested_at: nil) }
has_many :projects, -> { auto_include(false) }, through: :project_members has_many :projects, through: :project_members
has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :starred_projects, -> { auto_include(false) }, through: :users_star_projects, source: :project has_many :starred_projects, through: :users_star_projects, source: :project
has_many :project_authorizations has_many :project_authorizations
has_many :authorized_projects, -> { auto_include(false) }, through: :project_authorizations, source: :project has_many :authorized_projects, through: :project_authorizations, source: :project
has_many :user_interacted_projects has_many :user_interacted_projects
has_many :project_interactions, -> { auto_include(false) }, through: :user_interacted_projects, source: :project, class_name: 'Project' has_many :project_interactions, through: :user_interacted_projects, source: :project, class_name: 'Project'
has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
...@@ -132,7 +132,7 @@ class User < ActiveRecord::Base ...@@ -132,7 +132,7 @@ class User < ActiveRecord::Base
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignees has_many :issue_assignees
has_many :assigned_issues, -> { auto_include(false) }, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent
has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :custom_attributes, class_name: 'UserCustomAttribute'
...@@ -947,10 +947,13 @@ class User < ActiveRecord::Base ...@@ -947,10 +947,13 @@ class User < ActiveRecord::Base
end end
def manageable_groups def manageable_groups
union = Gitlab::SQL::Union.new([owned_groups.select(:id), union_sql = Gitlab::SQL::Union.new([owned_groups.select(:id), masters_groups.select(:id)]).to_sql
masters_groups.select(:id)])
arel_union = Arel::Nodes::SqlLiteral.new(union.to_sql) # Update this line to not use raw SQL when migrated to Rails 5.2.
owned_and_master_groups = Group.where(Group.arel_table[:id].in(arel_union)) # Either ActiveRecord or Arel constructions are fine.
# This was replaced with the raw SQL construction because of bugs in the arel gem.
# Bugs were fixed in arel 9.0.0 (Rails 5.2).
owned_and_master_groups = Group.where("namespaces.id IN (#{union_sql})") # rubocop:disable GitlabSecurity/SqlInjection
Gitlab::GroupHierarchy.new(owned_and_master_groups).base_and_descendants Gitlab::GroupHierarchy.new(owned_and_master_groups).base_and_descendants
end end
......
...@@ -265,6 +265,15 @@ class WikiPage ...@@ -265,6 +265,15 @@ class WikiPage
title.present? && self.class.unhyphenize(@page.url_path) != title title.present? && self.class.unhyphenize(@page.url_path) != title
end end
# Updates the current @attributes hash by merging a hash of params
def update_attributes(attrs)
attrs[:title] = process_title(attrs[:title]) if attrs[:title].present?
attrs.slice!(:content, :format, :message, :title)
@attributes.merge!(attrs)
end
private private
# Process and format the title based on the user input. # Process and format the title based on the user input.
...@@ -290,15 +299,6 @@ class WikiPage ...@@ -290,15 +299,6 @@ class WikiPage
File.join(components) File.join(components)
end end
# Updates the current @attributes hash by merging a hash of params
def update_attributes(attrs)
attrs[:title] = process_title(attrs[:title]) if attrs[:title].present?
attrs.slice!(:content, :format, :message, :title)
@attributes.merge!(attrs)
end
def set_attributes def set_attributes
attributes[:slug] = @page.url_path attributes[:slug] = @page.url_path
attributes[:title] = @page.title attributes[:title] = @page.title
......
module Ci module Ci
class BuildPresenter < Gitlab::View::Presenter::Delegated class BuildPresenter < Gitlab::View::Presenter::Delegated
CALLOUT_FAILURE_MESSAGES = {
unknown_failure: 'There is an unknown failure, please try again',
script_failure: 'There has been a script failure. Check the job log for more information',
api_failure: 'There has been an API failure, please try again',
stuck_or_timeout_failure: 'There has been a timeout failure or the job got stuck. Check your timeout limits or try again',
runner_system_failure: 'There has been a runner system failure, please try again',
missing_dependency_failure: 'There has been a missing dependency failure, check the job log for more information'
}.freeze
presents :build presents :build
def erased_by_user? def erased_by_user?
...@@ -35,6 +44,14 @@ module Ci ...@@ -35,6 +44,14 @@ module Ci
"#{subject.name} - #{detailed_status.status_tooltip}" "#{subject.name} - #{detailed_status.status_tooltip}"
end end
def callout_failure_message
CALLOUT_FAILURE_MESSAGES[failure_reason.to_sym]
end
def recoverable?
failed? && !unrecoverable?
end
private private
def tooltip_for_badge def tooltip_for_badge
...@@ -44,5 +61,9 @@ module Ci ...@@ -44,5 +61,9 @@ module Ci
def detailed_status def detailed_status
@detailed_status ||= subject.detailed_status(user) @detailed_status ||= subject.detailed_status(user)
end end
def unrecoverable?
script_failure? || missing_dependency_failure?
end
end end
end end
module EntityDateHelper module EntityDateHelper
include ActionView::Helpers::DateHelper include ActionView::Helpers::DateHelper
include ActionView::Helpers::TagHelper
def interval_in_words(diff) def interval_in_words(diff)
return 'Not started' unless diff return 'Not started' unless diff
...@@ -34,4 +35,30 @@ module EntityDateHelper ...@@ -34,4 +35,30 @@ module EntityDateHelper
duration_hash duration_hash
end end
# Generates an HTML-formatted string for remaining dates based on start_date and due_date
#
# It returns "Past due" for expired entities
# It returns "Upcoming" for upcoming entities
# If due date is provided, it returns "# days|weeks|months remaining|ago"
# If start date is provided and elapsed, with no due date, it returns "# days elapsed"
def remaining_days_in_words(entity)
if entity.try(:expired?)
content_tag(:strong, 'Past due')
elsif entity.try(:upcoming?)
content_tag(:strong, 'Upcoming')
elsif entity.due_date
is_upcoming = (entity.due_date - Date.today).to_i > 0
time_ago = time_ago_in_words(entity.due_date)
content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" }
content.slice!("about ")
content << " " + (is_upcoming ? _("remaining") : _("ago"))
content.html_safe
elsif entity.start_date && entity.start_date.past?
days = entity.elapsed_days
content = content_tag(:strong, days)
content << " #{'day'.pluralize(days)} elapsed"
content.html_safe
end
end
end end
...@@ -26,6 +26,8 @@ class JobEntity < Grape::Entity ...@@ -26,6 +26,8 @@ class JobEntity < Grape::Entity
expose :created_at expose :created_at
expose :updated_at expose :updated_at
expose :detailed_status, as: :status, with: StatusEntity expose :detailed_status, as: :status, with: StatusEntity
expose :callout_message, if: -> (*) { failed? }
expose :recoverable, if: -> (*) { failed? }
private private
...@@ -50,4 +52,20 @@ class JobEntity < Grape::Entity ...@@ -50,4 +52,20 @@ class JobEntity < Grape::Entity
def path_to(route, build) def path_to(route, build)
send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend
end end
def failed?
build.failed?
end
def callout_message
build_presenter.callout_failure_message
end
def recoverable
build_presenter.recoverable?
end
def build_presenter
@build_presenter ||= build.present
end
end end
...@@ -33,7 +33,7 @@ module Ci ...@@ -33,7 +33,7 @@ module Ci
end end
end end
builds.auto_include(false).find do |build| builds.find do |build|
next unless runner.can_pick?(build) next unless runner.can_pick?(build)
begin begin
......
...@@ -64,9 +64,14 @@ module Labels ...@@ -64,9 +64,14 @@ module Labels
end end
def update_label_links(labels, old_label_id:, new_label_id:) def update_label_links(labels, old_label_id:, new_label_id:)
LabelLink.joins(:label) # use 'labels' relation to get label_link ids only of issues/MRs
.merge(labels) # in the project being transferred.
.where(label_id: old_label_id) # IDs are fetched in a separate query because MySQL doesn't
# allow referring of 'label_links' table in UPDATE query:
# https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/62435068
link_ids = labels.pluck('label_links.id')
LabelLink.where(id: link_ids, label_id: old_label_id)
.update_all(label_id: new_label_id) .update_all(label_id: new_label_id)
end end
......
...@@ -8,9 +8,10 @@ module Projects ...@@ -8,9 +8,10 @@ module Projects
template_name = params.delete(:template_name) template_name = params.delete(:template_name)
file = Gitlab::ProjectTemplate.find(template_name).file file = Gitlab::ProjectTemplate.find(template_name).file
override_params = params.dup
params[:file] = file params[:file] = file
GitlabProjectsImportService.new(current_user, params).execute GitlabProjectsImportService.new(current_user, params, override_params).execute
ensure ensure
file&.close file&.close
......
...@@ -6,10 +6,10 @@ class JobArtifactUploader < GitlabUploader ...@@ -6,10 +6,10 @@ class JobArtifactUploader < GitlabUploader
storage_options Gitlab.config.artifacts storage_options Gitlab.config.artifacts
def size def cached_size
return super if model.size.nil? return model.size if model.size.present? && !model.file_changed?
model.size size
end end
def store_dir def store_dir
...@@ -20,7 +20,7 @@ class JobArtifactUploader < GitlabUploader ...@@ -20,7 +20,7 @@ class JobArtifactUploader < GitlabUploader
if file_storage? if file_storage?
File.open(path, "rb") if path File.open(path, "rb") if path
else else
::Gitlab::Ci::Trace::HttpIO.new(url, size) if url ::Gitlab::Ci::Trace::HttpIO.new(url, cached_size) if url
end end
end end
......
...@@ -183,14 +183,6 @@ module ObjectStorage ...@@ -183,14 +183,6 @@ module ObjectStorage
StoreURL: connection.put_object_url(remote_store_path, upload_path, expire_at, options) StoreURL: connection.put_object_url(remote_store_path, upload_path, expire_at, options)
} }
end end
def default_object_store
if self.object_store_enabled? && self.direct_upload_enabled?
Store::REMOTE
else
Store::LOCAL
end
end
end end
# allow to configure and overwrite the filename # allow to configure and overwrite the filename
...@@ -211,12 +203,13 @@ module ObjectStorage ...@@ -211,12 +203,13 @@ module ObjectStorage
end end
def object_store def object_store
@object_store ||= model.try(store_serialization_column) || self.class.default_object_store # We use Store::LOCAL as null value indicates the local storage
@object_store ||= model.try(store_serialization_column) || Store::LOCAL
end end
# rubocop:disable Gitlab/ModuleWithInstanceVariables # rubocop:disable Gitlab/ModuleWithInstanceVariables
def object_store=(value) def object_store=(value)
@object_store = value || self.class.default_object_store @object_store = value || Store::LOCAL
@storage = storage_for(object_store) @storage = storage_for(object_store)
end end
# rubocop:enable Gitlab/ModuleWithInstanceVariables # rubocop:enable Gitlab/ModuleWithInstanceVariables
...@@ -302,6 +295,15 @@ module ObjectStorage ...@@ -302,6 +295,15 @@ module ObjectStorage
super super
end end
def store!(new_file = nil)
# when direct upload is enabled, always store on remote storage
if self.class.object_store_enabled? && self.class.direct_upload_enabled?
self.object_store = Store::REMOTE
end
super
end
private private
def schedule_background_upload? def schedule_background_upload?
......
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
- if issue.milestone - if issue.milestone
%span.issuable-milestone.d-none.d-sm-inline-block %span.issuable-milestone.d-none.d-sm-inline-block
&nbsp; &nbsp;
= link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: issuable_milestone_tooltip_title(issue) } do = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do
= icon('clock-o') = icon('clock-o')
= issue.milestone.title = issue.milestone.title
- if issue.due_date - if issue.due_date
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment