Commit 192df7d9 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'master' into fl-prettify-2

* master: (74 commits)
  Add changelog for 2fa filter in users api
  Add 2FA filter to users API for admins only
  Fix project creation for user endpoint bug
  Flowdock uses Gitaly, not Grit
  Add missing changelog type to docs
  Removes 'no job log' from trace action
  Fix missing namespace for some internal users
  Dedupe yarn dependencies
  Downgrade MySQL CI service from 8.0 to 5.7
  Atomic internal ids for all models
  Add documentation on how to configure Redis Sentinel by persistent class
  Update CHANGELOG.md for 10.7.0
  Update index.md
  Resolve "Text from the diff is showing within a table header inside the discussion after the discussion is resolved"
  Ignore ordering in IssueDueSchedulerWorker spec
  Don't include lfs_file_locks data in export bundle
  Documentation: Frontend Building Checklist
  Fix a documentation typo for GitLab pages
  Refactored activity calendar
  Add an API endpoint to download git repository snapshots
  ...
parents 4e2b96d6 eb1cb7be
...@@ -75,7 +75,7 @@ stages: ...@@ -75,7 +75,7 @@ stages:
.use-mysql: &use-mysql .use-mysql: &use-mysql
services: services:
- mysql:latest - mysql:5.7
- redis:alpine - redis:alpine
.rails5-variables: &rails5-variables .rails5-variables: &rails5-variables
......
This diff is collapsed.
...@@ -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.)
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 | Estimate time to fix | Guidance |
|-------|-----------------|------------------------------------------------------------------|----------|
| ~P1 | Urgent Priority | The current release | |
| ~P2 | High Priority | The next release | |
| ~P3 | Medium Priority | Within the next 3 releases (approx one quarter) | |
| ~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. Severity labels help us clearly communicate the impact of a ~bug on users.
| Label | Meaning | Example | | Label | Meaning | Impact of the defect | Example |
|-------|------------------------------------------|---------| |-------|-------------------|-------------------------------------------------------|---------|
| ~S1 | Feature broken, no workaround | Unable to create an issue | | ~S1 | Blocker | Outage, broken feature with no workaround | Unable to create an issue. Data corruption/loss. Security breach. |
| ~S2 | Feature broken, workaround unacceptable | Can push commits, but only via the command line | | ~S2 | Critical Severity | Broken Feature, workaround too complex & unacceptable | Can push commits, but only via the command line. |
| ~S3 | Feature broken, workaround acceptable | Can create merge requests only from the Merge Requests page, not through the Issue | | ~S3 | Major Severity | Broken Feature, workaround acceptable | Can create merge requests only from the Merge Requests page, not through the Issue. |
| ~S4 | Cosmetic issue | Label colors are incorrect / not being displayed | | ~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")
......
...@@ -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'
......
...@@ -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)
...@@ -747,7 +747,7 @@ GEM ...@@ -747,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)
...@@ -1160,7 +1160,7 @@ DEPENDENCIES ...@@ -1160,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).
......
...@@ -113,6 +113,8 @@ class List { ...@@ -113,6 +113,8 @@ class List {
issue.id = data.id; issue.id = data.id;
issue.iid = data.iid; issue.iid = data.iid;
issue.project = data.project; issue.project = data.project;
issue.path = data.real_path;
issue.referencePath = data.reference_path;
if (this.issuesSize > 1) { if (this.issuesSize > 1) {
const moveBeforeId = this.issues[1].id; const moveBeforeId = this.issues[1].id;
......
...@@ -84,20 +84,21 @@ export default class CreateMergeRequestDropdown { ...@@ -84,20 +84,21 @@ export default class CreateMergeRequestDropdown {
if (data.can_create_branch) { if (data.can_create_branch) {
this.available(); this.available();
this.enable(); this.enable();
this.updateBranchName(data.suggested_branch_name);
if (!this.droplabInitialized) { if (!this.droplabInitialized) {
this.droplabInitialized = true; this.droplabInitialized = true;
this.initDroplab(); this.initDroplab();
this.bindEvents(); this.bindEvents();
} }
} else if (data.has_related_branch) { } else {
this.hide(); this.hide();
} }
}) })
.catch(() => { .catch(() => {
this.unavailable(); this.unavailable();
this.disable(); this.disable();
Flash('Failed to check if a new branch can be created.'); Flash(__('Failed to check related branches.'));
}); });
} }
...@@ -409,13 +410,16 @@ export default class CreateMergeRequestDropdown { ...@@ -409,13 +410,16 @@ export default class CreateMergeRequestDropdown {
this.unavailableButton.classList.remove('hide'); this.unavailableButton.classList.remove('hide');
} }
updateBranchName(suggestedBranchName) {
this.branchInput.value = suggestedBranchName;
this.updateCreatePaths('branch', suggestedBranchName);
}
updateInputState(target, ref, result) { updateInputState(target, ref, result) {
// target - 'branch' or 'ref' - which the input field we are searching a ref for. // target - 'branch' or 'ref' - which the input field we are searching a ref for.
// ref - string - what a user typed. // ref - string - what a user typed.
// result - string - what has been found on backend. // result - string - what has been found on backend.
const pathReplacement = `$1${ref}`;
// If a found branch equals exact the same text a user typed, // If a found branch equals exact the same text a user typed,
// that means a new branch cannot be created as it already exists. // that means a new branch cannot be created as it already exists.
if (ref === result) { if (ref === result) {
...@@ -426,18 +430,12 @@ export default class CreateMergeRequestDropdown { ...@@ -426,18 +430,12 @@ export default class CreateMergeRequestDropdown {
this.refIsValid = true; this.refIsValid = true;
this.refInput.dataset.value = ref; this.refInput.dataset.value = ref;
this.showAvailableMessage('ref'); this.showAvailableMessage('ref');
this.createBranchPath = this.createBranchPath.replace(this.regexps.ref.createBranchPath, this.updateCreatePaths(target, ref);
pathReplacement);
this.createMrPath = this.createMrPath.replace(this.regexps.ref.createMrPath,
pathReplacement);
} }
} else if (target === 'branch') { } else if (target === 'branch') {
this.branchIsValid = true; this.branchIsValid = true;
this.showAvailableMessage('branch'); this.showAvailableMessage('branch');
this.createBranchPath = this.createBranchPath.replace(this.regexps.branch.createBranchPath, this.updateCreatePaths(target, ref);
pathReplacement);
this.createMrPath = this.createMrPath.replace(this.regexps.branch.createMrPath,
pathReplacement);
} else { } else {
this.refIsValid = false; this.refIsValid = false;
this.refInput.dataset.value = ref; this.refInput.dataset.value = ref;
...@@ -457,4 +455,15 @@ export default class CreateMergeRequestDropdown { ...@@ -457,4 +455,15 @@ export default class CreateMergeRequestDropdown {
this.disableCreateAction(); this.disableCreateAction();
} }
} }
// target - 'branch' or 'ref'
// ref - string - the new value to use as branch or ref
updateCreatePaths(target, ref) {
const pathReplacement = `$1${ref}`;
this.createBranchPath = this.createBranchPath.replace(this.regexps[target].createBranchPath,
pathReplacement);
this.createMrPath = this.createMrPath.replace(this.regexps[target].createMrPath,
pathReplacement);
}
} }
...@@ -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>
<span
v-tooltip
:title="tooltipTitle"
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 <icon
v-if="file.changed || file.tempFile || (file.staged && !showStagedIcon)"
:name="changedIcon" :name="changedIcon"
:size="12" :size="12"
:css-classes="`ide-file-changed-icon ${changedIconClass}`" :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: { props: {
title: { title: {
...@@ -19,38 +24,109 @@ ...@@ -19,38 +24,109 @@
type: Array, type: Array,
required: true, required: true,
}, },
showToggle: {
type: Boolean,
required: false,
default: true,
},
iconName: {
type: String,
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: { computed: {
...mapState([ ...mapState(['rightPanelCollapsed']),
'currentProjectId', ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
'currentBranchId', titleText() {
'rightPanelCollapsed', return sprintf(__('%{title} changes'), {
]), title: this.title,
isCommitInfoShown() { });
return this.rightPanelCollapsed || this.fileList.length;
}, },
}, },
methods: { methods: {
toggleCollapsed() { ...mapActions(['toggleRightPanelCollapsed', 'stageAllChanges', 'unstageAllChanges']),
this.$emit('toggleCollapsed'); actionBtnClicked() {
this[this.action]();
}, },
}, },
}; };
</script> </script>
<template> <template>
<div <div
class="ide-commit-list-container"
:class="{
'is-collapsed': rightPanelCollapsed,
}"
>
<header
class="multi-file-commit-panel-header"
>
<div
v-if="!rightPanelCollapsed"
class="multi-file-commit-panel-header-title"
:class="{ :class="{
'multi-file-commit-list': isCommitInfoShown '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,
},
iconName: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
}, },
computed: { computed: {
...mapGetters([ addedFilesLength() {
'addedFiles', return this.files.filter(f => f.tempFile).length;
'modifiedFiles', },
]), 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"
>
<div
v-tooltip
:title="titleTooltip"
data-container="body"
data-placement="left"
class="append-bottom-15"
> >
<icon <icon
name="file-addition" v-once
:name="iconName"
:size="18" :size="18"
css-classes="multi-file-addition append-bottom-10"
/> />
{{ addedFiles.length }} </div>
<div
v-tooltip
:title="additionsTooltip"
data-container="body"
data-placement="left"
class="append-bottom-10"
>
<icon <icon
name="file-modified" :name="additionIconName"
:size="18" :size="18"
css-classes="multi-file-modified prepend-top-10 append-bottom-10" :css-classes="addedFilesIconClass"
/> />
{{ modifiedFiles.length }} </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"
......
<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>
<template
v-if="changedFiles.length || stagedFiles.length"
>
<commit-files-list <commit-files-list
title="Staged" icon-name="unstaged"
:title="__('Unstaged')"
:file-list="changedFiles" :file-list="changedFiles"
:collapsed="rightPanelCollapsed" action="stageAllChanges"
@toggleCollapsed="toggleCollapsed" :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"
/> />
<template
v-if="changedFiles.length"
>
<form <form
class="form-horizontal multi-file-commit-form" class="form-horizontal 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"
class="row js-empty-state"
>
<div class="col-xs-10 col-xs-offset-1">
<div class="svg-content svg-80">
<img :src="statusSvg" />
</div>
</div>
<div class="col-xs-10 col-xs-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 v-else
> :no-changes-state-svg-path="noChangesStateSvgPath"
<h4> :committed-state-svg-path="committedStateSvgPath"
{{ __('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) {
if (this.decorationsController.hasDecorations(model)) {
this.decorationsController.decorate(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.content.${file.path}`, file.raw); 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);
}
};
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,
......
...@@ -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(() => {
......
...@@ -1427,7 +1427,7 @@ export default class Notes { ...@@ -1427,7 +1427,7 @@ export default class Notes {
const { discussion_html } = data; const { discussion_html } = data;
const lines = $(discussion_html).find('.line_holder'); const lines = $(discussion_html).find('.line_holder');
lines.addClass('fade-in'); lines.addClass('fade-in');
$container.find('tbody').prepend(lines); $container.find('.diff-content > table > tbody').prepend(lines);
const fileHolder = $container.find('.file-holder'); const fileHolder = $container.find('.file-holder');
$container.find('.line-holder-placeholder').remove(); $container.find('.line-holder-placeholder').remove();
syntaxHighlight(fileHolder); syntaxHighlight(fileHolder);
......
...@@ -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,19 +160,20 @@ export default class ActivityCalendar { ...@@ -133,19 +160,20 @@ 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('title', stamp => formatTooltipText(stamp))
.attr('class', 'user-contrib-cell js-tooltip') .attr('class', 'user-contrib-cell js-tooltip')
.attr('data-container', 'body') .attr('data-container', 'body')
...@@ -156,16 +184,19 @@ export default class ActivityCalendar { ...@@ -156,16 +184,19 @@ export default class ActivityCalendar {
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()
...@@ -178,7 +209,8 @@ export default class ActivityCalendar { ...@@ -178,7 +209,8 @@ export default class ActivityCalendar {
} }
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)
...@@ -191,11 +223,24 @@ export default class ActivityCalendar { ...@@ -191,11 +223,24 @@ export default class ActivityCalendar {
} }
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()
...@@ -211,8 +256,17 @@ export default class ActivityCalendar { ...@@ -211,8 +256,17 @@ export default class ActivityCalendar {
} }
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,7 +281,8 @@ export default class ActivityCalendar { ...@@ -227,7 +281,8 @@ export default class ActivityCalendar {
$('.user-calendar-activities').html(LOADING_HTML); $('.user-calendar-activities').html(LOADING_HTML);
axios.get(this.calendarActivitiesPath, { axios
.get(this.calendarActivitiesPath, {
params: { params: {
date, date,
}, },
......
...@@ -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> <script>
export default { import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'ToggleSidebar', name: 'ToggleSidebar',
directives: {
tooltip,
},
props: { props: {
collapsed: { collapsed: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
}, },
computed: {
tooltipLabel() {
return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar');
},
},
methods: { methods: {
toggle() { toggle() {
this.$emit('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"
......
...@@ -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
......
...@@ -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 {
...@@ -525,9 +529,13 @@ ...@@ -525,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 {
...@@ -536,35 +544,22 @@ ...@@ -536,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 {
...@@ -578,12 +573,14 @@ ...@@ -578,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 {
...@@ -595,26 +592,31 @@ ...@@ -595,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 {
...@@ -626,7 +628,7 @@ ...@@ -626,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;
...@@ -811,6 +813,41 @@ ...@@ -811,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;
......
...@@ -134,11 +134,11 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -134,11 +134,11 @@ class Projects::IssuesController < Projects::ApplicationController
def can_create_branch def can_create_branch
can_create = current_user && can_create = current_user &&
can?(current_user, :push_code, @project) && can?(current_user, :push_code, @project) &&
@issue.can_be_worked_on?(current_user) @issue.can_be_worked_on?
respond_to do |format| respond_to do |format|
format.json do format.json do
render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? } render json: { can_create_branch: can_create, suggested_branch_name: @issue.suggested_branch_name }
end end
end end
end end
...@@ -177,7 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -177,7 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController
end end
def authorize_create_merge_request! def authorize_create_merge_request!
render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?
end end
def render_issue_json def render_issue_json
......
...@@ -78,8 +78,6 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -78,8 +78,6 @@ 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
......
...@@ -32,6 +32,7 @@ class UsersFinder ...@@ -32,6 +32,7 @@ class UsersFinder
users = by_active(users) users = by_active(users)
users = by_external_identity(users) users = by_external_identity(users)
users = by_external(users) users = by_external(users)
users = by_2fa(users)
users = by_created_at(users) users = by_created_at(users)
users = by_custom_attributes(users) users = by_custom_attributes(users)
...@@ -76,4 +77,15 @@ class UsersFinder ...@@ -76,4 +77,15 @@ class UsersFinder
users.external users.external
end end
def by_2fa(users)
case params[:two_factor]
when 'enabled'
users.with_two_factor
when 'disabled'
users.without_two_factor
else
users
end
end
end end
...@@ -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 }
if labels && labels.any?
label_names = first.collect(&:name) label_names = first.collect(&:name)
label_names << "and #{last.size} more" unless last.empty? 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 progress-bar-success', class: 'progress-bar progress-bar-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
......
...@@ -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,
...@@ -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
...@@ -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
......
...@@ -27,8 +27,9 @@ module AtomicInternalId ...@@ -27,8 +27,9 @@ module AtomicInternalId
module ClassMethods module ClassMethods
def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName
before_validation(on: :create) do before_validation(on: :create) do
if read_attribute(column).blank? scope_value = association(scope).reader
scope_attrs = { scope => association(scope).reader } if read_attribute(column).blank? && scope_value
scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value }
usage = self.class.table_name.to_sym usage = self.class.table_name.to_sym
new_iid = InternalId.generate_next(self, scope_attrs, usage, init) new_iid = InternalId.generate_next(self, scope_attrs, usage, init)
......
...@@ -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
......
module NonatomicInternalId
extend ActiveSupport::Concern
included do
validate :set_iid, on: :create
validates :iid, presence: true, numericality: true
end
def set_iid
if iid.blank?
parent = project || group
records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend
max_iid = records.maximum(:iid)
self.iid = max_iid.to_i + 1
end
end
def to_param
iid.to_s
end
end
# Uniquify
#
# Return a version of the given 'base' string that is unique
# by appending a counter to it. Uniqueness is determined by
# repeated calls to the passed block.
#
# You can pass an initial value for the counter, if not given
# counting starts from 1.
#
# If `base` is a function/proc, we expect that calling it with a
# candidate counter returns a string to test/return.
class Uniquify class Uniquify
# Return a version of the given 'base' string that is unique def initialize(counter = nil)
# by appending a counter to it. Uniqueness is determined by @counter = counter
# repeated calls to the passed block. end
#
# If `base` is a function/proc, we expect that calling it with a
# candidate counter returns a string to test/return.
def string(base) def string(base)
@base = base @base = base
@counter = nil
increment_counter! while yield(base_string) increment_counter! while yield(base_string)
base_string base_string
......
class Deployment < ActiveRecord::Base class Deployment < ActiveRecord::Base
include NonatomicInternalId include AtomicInternalId
belongs_to :project, required: true belongs_to :project, required: true
belongs_to :environment, required: true belongs_to :environment, required: true
belongs_to :user belongs_to :user
belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.deployments&.maximum(:iid) }
validates :sha, presence: true validates :sha, presence: true
validates :ref, presence: true validates :ref, presence: true
......
...@@ -12,8 +12,9 @@ ...@@ -12,8 +12,9 @@
# * (Optionally) add columns to `internal_ids` if needed for scope. # * (Optionally) add columns to `internal_ids` if needed for scope.
class InternalId < ActiveRecord::Base class InternalId < ActiveRecord::Base
belongs_to :project belongs_to :project
belongs_to :namespace
enum usage: { issues: 0 } enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4 }
validates :usage, presence: true validates :usage, presence: true
......
...@@ -194,6 +194,15 @@ class Issue < ActiveRecord::Base ...@@ -194,6 +194,15 @@ class Issue < ActiveRecord::Base
branches_with_iid - branches_with_merge_request branches_with_iid - branches_with_merge_request
end end
def suggested_branch_name
return to_branch_name unless project.repository.branch_exists?(to_branch_name)
start_counting_from = 2
Uniquify.new(start_counting_from).string(-> (counter) { "#{to_branch_name}-#{counter}" }) do |suggested_branch_name|
project.repository.branch_exists?(suggested_branch_name)
end
end
# Returns boolean if a related branch exists for the current issue # Returns boolean if a related branch exists for the current issue
# ignores merge requests branchs # ignores merge requests branchs
def has_related_branch? def has_related_branch?
...@@ -248,11 +257,8 @@ class Issue < ActiveRecord::Base ...@@ -248,11 +257,8 @@ class Issue < ActiveRecord::Base
end end
end end
def can_be_worked_on?(current_user) def can_be_worked_on?
!self.closed? && !self.closed? && !self.project.forked?
!self.project.forked? &&
self.related_branches(current_user).empty? &&
self.closed_by_merge_requests(current_user).empty?
end end
# Returns `true` if the current issue can be viewed by either a logged in User # Returns `true` if the current issue can be viewed by either a logged in User
......
...@@ -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)
......
class MergeRequest < ActiveRecord::Base class MergeRequest < ActiveRecord::Base
include NonatomicInternalId include AtomicInternalId
include Issuable include Issuable
include Noteable include Noteable
include Referable include Referable
...@@ -18,6 +18,8 @@ class MergeRequest < ActiveRecord::Base ...@@ -18,6 +18,8 @@ class MergeRequest < ActiveRecord::Base
belongs_to :source_project, class_name: "Project" belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User" belongs_to :merge_user, class_name: "User"
has_internal_id :iid, scope: :target_project, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
has_many :merge_request_diffs has_many :merge_request_diffs
has_one :merge_request_diff, has_one :merge_request_diff,
......
...@@ -8,7 +8,7 @@ class Milestone < ActiveRecord::Base ...@@ -8,7 +8,7 @@ class Milestone < ActiveRecord::Base
Started = MilestoneStruct.new('Started', '#started', -3) Started = MilestoneStruct.new('Started', '#started', -3)
include CacheMarkdownField include CacheMarkdownField
include NonatomicInternalId include AtomicInternalId
include Sortable include Sortable
include Referable include Referable
include StripAttribute include StripAttribute
...@@ -21,6 +21,9 @@ class Milestone < ActiveRecord::Base ...@@ -21,6 +21,9 @@ class Milestone < ActiveRecord::Base
belongs_to :project belongs_to :project
belongs_to :group belongs_to :group
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
has_many :issues has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests has_many :merge_requests
......
require "flowdock-git-hook" require "flowdock-git-hook"
# Flow dock depends on Grit to compute the number of commits between two given
# commits. To make this depend on Gitaly, a monkey patch is applied
module Flowdock
class Git
# pass down a Repository all the way down
def repo
@options[:repo]
end
def config
{}
end
def messages
Git::Builder.new(repo: repo,
ref: @ref,
before: @from,
after: @to,
commit_url: @commit_url,
branch_url: @branch_url,
diff_url: @diff_url,
repo_url: @repo_url,
repo_name: @repo_name,
permanent_refs: @permanent_refs,
tags: tags
).to_hashes
end
class Builder
def commits
@repo.commits_between(@before, @after).map do |commit|
{
url: @opts[:commit_url] ? @opts[:commit_url] % [commit.sha] : nil,
id: commit.sha,
message: commit.message,
author: {
name: commit.author_name,
email: commit.author_email
}
}
end
end
end
end
end
class FlowdockService < Service class FlowdockService < Service
prop_accessor :token prop_accessor :token
validates :token, presence: true, if: :activated? validates :token, presence: true, if: :activated?
...@@ -34,7 +80,7 @@ class FlowdockService < Service ...@@ -34,7 +80,7 @@ class FlowdockService < Service
data[:before], data[:before],
data[:after], data[:after],
token: token, token: token,
repo: project.repository.path_to_repo, repo: project.repository,
repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}", repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}",
commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/%s", commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/%s",
diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s" diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s"
......
...@@ -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
...@@ -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
......
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
...@@ -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.hidden-xs %span.issuable-milestone.hidden-xs
&nbsp; &nbsp;
= link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(issue) } do = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, 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
......
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
- if merge_request.milestone - if merge_request.milestone
%span.issuable-milestone.hidden-xs %span.issuable-milestone.hidden-xs
&nbsp; &nbsp;
= link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(merge_request) } do = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_due_date(merge_request.milestone) } do
= icon('clock-o') = icon('clock-o')
= merge_request.milestone.title = merge_request.milestone.title
- if merge_request.target_project.default_branch != merge_request.target_branch - if merge_request.target_project.default_branch != merge_request.target_branch
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
- if current_user - if current_user
%span.issuable-header-text.hide-collapsed.pull-left %span.issuable-header-text.hide-collapsed.pull-left
= _('Todo') = _('Todo')
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } %a.gutter-toggle.pull-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left' } }
= sidebar_gutter_toggle_icon = sidebar_gutter_toggle_icon
- if current_user - if current_user
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable
...@@ -19,11 +19,10 @@ ...@@ -19,11 +19,10 @@
.block.assignee .block.assignee
= render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present? = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present?
.block.milestone .block.milestone
.sidebar-collapsed-icon .sidebar-collapsed-icon.has-tooltip{ title: milestone_tooltip_title(issuable.milestone), data: { container: 'body', html: 1, placement: 'left' } }
= icon('clock-o', 'aria-hidden': 'true') = icon('clock-o', 'aria-hidden': 'true')
%span.milestone-title %span.milestone-title
- if issuable.milestone - if issuable.milestone
%span.has-tooltip{ title: "#{issuable.milestone.title}<br>#{milestone_tooltip_title(issuable.milestone)}", data: { container: 'body', html: 1, placement: 'left' } }
= issuable.milestone.title = issuable.milestone.title
- else - else
= _('None') = _('None')
...@@ -34,7 +33,7 @@ ...@@ -34,7 +33,7 @@
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed .value.hide-collapsed
- if issuable.milestone - if issuable.milestone
= link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_title(issuable.milestone), data: { container: "body", html: 1 } = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_due_date(issuable.milestone), data: { container: "body", html: 1 }
- else - else
%span.no-value %span.no-value
= _('None') = _('None')
...@@ -50,7 +49,7 @@ ...@@ -50,7 +49,7 @@
= icon('spinner spin', 'aria-hidden': 'true') = icon('spinner spin', 'aria-hidden': 'true')
- if issuable.has_attribute?(:due_date) - if issuable.has_attribute?(:due_date)
.block.due_date .block.due_date
.sidebar-collapsed-icon .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 1 }, title: sidebar_due_date_tooltip_label(issuable) }
= icon('calendar', 'aria-hidden': 'true') = icon('calendar', 'aria-hidden': 'true')
%span.js-due-date-sidebar-value %span.js-due-date-sidebar-value
= issuable.due_date.try(:to_s, :medium) || 'None' = issuable.due_date.try(:to_s, :medium) || 'None'
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
= _('Assignee') = _('Assignee')
= icon('spinner spin') = icon('spinner spin')
- else - else
.sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) }
- if issuable.assignee - if issuable.assignee
= link_to_member(@project, issuable.assignee, size: 24) = link_to_member(@project, issuable.assignee, size: 24)
- else - else
......
- is_collapsed = local_assigns.fetch(:is_collapsed, false) - is_collapsed = local_assigns.fetch(:is_collapsed, false)
- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark done') - mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark todo as done')
- todo_content = is_collapsed ? icon('plus-square') : _('Add todo') - todo_content = is_collapsed ? icon('plus-square') : _('Add todo')
%button.issuable-todo-btn.js-issuable-todo{ type: 'button', %button.issuable-todo-btn.js-issuable-todo{ type: 'button',
class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn pull-right'), class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn pull-right'),
title: (todo.nil? ? _('Add todo') : _('Mark done')), title: (todo.nil? ? _('Add todo') : _('Mark todo as done')),
'aria-label' => (todo.nil? ? _('Add todo') : _('Mark done')), 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark todo as done')),
data: issuable_todo_button_data(issuable, todo, is_collapsed) } data: issuable_todo_button_data(issuable, todo, is_collapsed) }
%span.issuable-todo-inner.js-issuable-todo-inner< %span.issuable-todo-inner.js-issuable-todo-inner<
- if todo - if todo
......
- merge_request = issuable - merge_request = issuable
.block.assignee .block.assignee
.sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) } .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) }
- if merge_request.assignee - if merge_request.assignee
= link_to_member(@project, merge_request.assignee, size: 24) = link_to_member(@project, merge_request.assignee, size: 24)
- else - else
......
...@@ -4,12 +4,8 @@ ...@@ -4,12 +4,8 @@
%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar.milestone-sidebar .issuable-sidebar.milestone-sidebar
.block.milestone-progress.issuable-sidebar-header .block.milestone-progress.issuable-sidebar-header
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } %a.gutter-toggle.pull-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left' } }
= sidebar_gutter_toggle_icon = sidebar_gutter_toggle_icon
.sidebar-collapsed-icon
%span== #{milestone.percent_complete(current_user)}%
= milestone_progress_bar(milestone)
.title.hide-collapsed .title.hide-collapsed
%strong.bold== #{milestone.percent_complete(current_user)}% %strong.bold== #{milestone.percent_complete(current_user)}%
%span.hide-collapsed %span.hide-collapsed
...@@ -17,6 +13,11 @@ ...@@ -17,6 +13,11 @@
.value.hide-collapsed .value.hide-collapsed
= milestone_progress_bar(milestone) = milestone_progress_bar(milestone)
.block.milestone-progress.hide-expanded
.sidebar-collapsed-icon.has-tooltip{ title: milestone_progress_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } }
%span== #{milestone.percent_complete(current_user)}%
= milestone_progress_bar(milestone)
.block.start_date.hide-collapsed .block.start_date.hide-collapsed
.title .title
Start date Start date
...@@ -35,18 +36,24 @@ ...@@ -35,18 +36,24 @@
%span.collapsed-milestone-date %span.collapsed-milestone-date
- if milestone.start_date && milestone.due_date - if milestone.start_date && milestone.due_date
- if milestone.start_date.year == milestone.due_date.year - if milestone.start_date.year == milestone.due_date.year
.milestone-date= milestone.start_date.strftime('%b %-d') .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } }
= milestone.start_date.strftime('%b %-d')
- else - else
.milestone-date= milestone.start_date.strftime('%b %-d %Y') .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } }
= milestone.start_date.strftime('%b %-d %Y')
.date-separator - .date-separator -
.due_date= milestone.due_date.strftime('%b %-d %Y') .due_date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 1, placement: 'left' } }
= milestone.due_date.strftime('%b %-d %Y')
- elsif milestone.start_date - elsif milestone.start_date
From From
.milestone-date= milestone.start_date.strftime('%b %-d %Y') .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } }
= milestone.start_date.strftime('%b %-d %Y')
- elsif milestone.due_date - elsif milestone.due_date
Until Until
.milestone-date= milestone.due_date.strftime('%b %-d %Y') .milestone-date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 1, placement: 'left' } }
= milestone.due_date.strftime('%b %-d %Y')
- else - else
.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } }
None None
.title.hide-collapsed .title.hide-collapsed
Due date Due date
...@@ -58,14 +65,14 @@ ...@@ -58,14 +65,14 @@
%span.bold= milestone.due_date.to_s(:medium) %span.bold= milestone.due_date.to_s(:medium)
- else - else
%span.no-value No due date %span.no-value No due date
- remaining_days = milestone_remaining_days(milestone) - remaining_days = remaining_days_in_words(milestone)
- if remaining_days.present? - if remaining_days.present?
= surround '(', ')' do = surround '(', ')' do
%span.remaining-days= remaining_days %span.remaining-days= remaining_days
- if !project || can?(current_user, :read_issue, project) - if !project || can?(current_user, :read_issue, project)
.block.issues .block.issues
.sidebar-collapsed-icon .sidebar-collapsed-icon.has-tooltip{ title: milestone_issues_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } }
%strong %strong
= custom_icon('issues') = custom_icon('issues')
%span= milestone.issues_visible_to_user(current_user).count %span= milestone.issues_visible_to_user(current_user).count
...@@ -93,7 +100,7 @@ ...@@ -93,7 +100,7 @@
= icon('spinner spin') = icon('spinner spin')
.block.merge-requests .block.merge-requests
.sidebar-collapsed-icon .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } }
%strong %strong
= custom_icon('mr_bold') = custom_icon('mr_bold')
%span= milestone.merge_requests.count %span= milestone.merge_requests.count
......
---
title: Enable restore rake task to handle nested storage directories
merge_request: 17516
author: Balasankar C
type: fixed
---
title: Add support for patch link extension for commit links on GitLab Flavored Markdown
merge_request:
author:
type: added
---
title: Include matching branches and tags in protected branches / tags count
merge_request:
author: Jan Beckmann
type: fixed
---
title: Send notification emails when push to a merge request
merge_request: 7610
author: YarNayar
type: feature
---
title: Improve tooltips in collapsed right sidebar
merge_request: 17714
author:
type: changed
---
title: Atomic generation of internal ids for issues.
merge_request: 17580
author:
type: other
---
title: Create Deploy Tokens to allow permanent access to repository and registry
merge_request: 17894
author:
type: added
---
title: Drop JSON response in Project Milestone along with avoiding error
merge_request: 17977
author: Takuya Noguchi
type: fixed
---
title: Fix generated URL when listing repoitories for import
merge_request: 17692
author:
type: fixed
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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