Merge remote-tracking branch 'upstream/master' into 14995-custom_wiki_sidebar

* upstream/master: (467 commits)
  Update docs board features tier
  Upgrade grape-path-helpers to 1.0.6
  Remove healthchecks from prometheus endpoint
  Fix find_branch call sites
  Ensure Encoding.default_external is set to UTF-8 when running QA scenarios
  i18n: externalize strings from 'app/views/admin/groups'
  Backport mr widget changes from EE
  Allow to toggle notifications for issues due soon
  Vuex test helper improvements
  whitespace
  Make more ref RPC's mandatory
  Resolve "Improve performance of MR Changes tab: reduce event listeners on scroll event"
  Remove old service architecture from Vue docs
  Adding spec to test basic forking functionalities
  Fix performance problem of accessing tag list for projects api endpoints
  typo
  Add sleep to QA test before installing tiller
  Include Vue files that are not covered by tests in test coverage
  Remove Repository#path memoization
  Resolve "do not set updated_at when creating note"
  ...
This commit is contained in:
Lin Jen-Shin 2018-07-13 17:34:27 +08:00
commit d9f26586b4
1739 changed files with 58401 additions and 33850 deletions

View File

@ -1,11 +1,18 @@
*.erb
lib/gitlab/sanitizers/svg/whitelist.rb
lib/gitlab/diff/position_tracer.rb
app/controllers/projects/approver_groups_controller.rb
app/controllers/projects/approvers_controller.rb
app/controllers/projects/protected_branches/merge_access_levels_controller.rb
app/controllers/projects/protected_branches/push_access_levels_controller.rb
app/controllers/projects/protected_tags/create_access_levels_controller.rb
app/policies/project_policy.rb
app/models/concerns/relative_positioning.rb
app/workers/stuck_merge_jobs_worker.rb
lib/gitlab/redis/*.rb
lib/gitlab/gitaly_client/operation_service.rb
app/models/project_services/packagist_service.rb
lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb
lib/gitlab/background_migration/*
app/models/project_services/kubernetes_service.rb
lib/gitlab/workhorse.rb
@ -19,6 +26,8 @@ ee/db/**/*
ee/app/serializers/ee/merge_request_widget_entity.rb
ee/lib/api/epics.rb
ee/lib/api/geo_nodes.rb
ee/lib/ee/api/group_boards.rb
ee/lib/ee/api/boards.rb
ee/lib/ee/gitlab/ldap/sync/admin_users.rb
ee/app/workers/geo/file_download_dispatch_worker/job_artifact_job_finder.rb
ee/app/workers/geo/file_download_dispatch_worker/lfs_object_job_finder.rb

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
Dangerfile gitlab-language=ruby

View File

@ -269,10 +269,10 @@ package-and-qa:
<<: *single-script-job-variables
SCRIPT_NAME: trigger-build-docs
environment:
name: review-docs/$CI_COMMIT_REF_NAME
name: review-docs/$CI_COMMIT_REF_SLUG
# DOCS_REVIEW_APPS_DOMAIN and DOCS_GITLAB_REPO_SUFFIX are secret variables
# Discussion: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14236/diffs#note_40140693
url: http://$DOCS_GITLAB_REPO_SUFFIX-$CI_ENVIRONMENT_SLUG.$DOCS_REVIEW_APPS_DOMAIN/$DOCS_GITLAB_REPO_SUFFIX
url: http://$CI_ENVIRONMENT_SLUG.$DOCS_REVIEW_APPS_DOMAIN/$DOCS_GITLAB_REPO_SUFFIX
on_stop: review-docs-cleanup
# Trigger a manual docs build in gitlab-docs only on non docs-only branches.
@ -285,7 +285,8 @@ review-docs-deploy-manual:
- ./$SCRIPT_NAME deploy
when: manual
only:
- branches
- branches@gitlab-org/gitlab-ce
- branches@gitlab-org/gitlab-ee
<<: *except-docs-and-qa
# Always trigger a docs build in gitlab-docs only on docs-only branches.
@ -297,7 +298,8 @@ review-docs-deploy:
- gem install gitlab --no-ri --no-rdoc
- ./$SCRIPT_NAME deploy
only:
- /(^docs[\/-].*|.*-docs$)/
- /(^docs[\/-].*|.*-docs$)/@gitlab-org/gitlab-ce
- /(^docs[\/-].*|.*-docs$)/@gitlab-org/gitlab-ee
<<: *except-qa
# Cleanup remote environment of gitlab-docs
@ -305,7 +307,7 @@ review-docs-cleanup:
<<: *review-docs
stage: post-cleanup
environment:
name: review-docs/$CI_COMMIT_REF_NAME
name: review-docs/$CI_COMMIT_REF_SLUG
action: stop
when: manual
script:
@ -323,11 +325,9 @@ cloud-native-image:
variables:
GIT_DEPTH: "1"
cache: {}
before_script:
- gem install gitlab --no-rdoc --no-ri
- chmod 755 ./scripts/trigger-build-cloud-native
script:
- ./scripts/trigger-build-cloud-native
- gem install gitlab --no-ri --no-rdoc
- ./scripts/trigger-build cng
only:
- tags@gitlab-org/gitlab-ce
- tags@gitlab-org/gitlab-ee
@ -348,6 +348,24 @@ retrieve-tests-metadata:
- wget -O $FLAKY_RSPEC_SUITE_REPORT_PATH http://${TESTS_METADATA_S3_BUCKET}.s3.amazonaws.com/$FLAKY_RSPEC_SUITE_REPORT_PATH || rm $FLAKY_RSPEC_SUITE_REPORT_PATH
- '[[ -f $FLAKY_RSPEC_SUITE_REPORT_PATH ]] || echo "{}" > ${FLAKY_RSPEC_SUITE_REPORT_PATH}'
danger-review:
image: registry.gitlab.com/gitlab-org/gitaly/dangercontainer:latest
stage: prepare
before_script:
- source scripts/utils.sh
- retry gem install danger --no-ri --no-rdoc
cache: {}
only:
refs:
- branches@gitlab-org/gitlab-ce
- branches@gitlab-org/gitlab-ee
except:
variables:
- $CI_COMMIT_REF_NAME =~ /^ce-to-ee-.*/
script:
- git version
- danger --fail-on-errors=true
update-tests-metadata:
<<: *tests-metadata-state
<<: *only-canonical-masters

View File

@ -39,6 +39,7 @@ Set the title to: `[Security] Description of the original issue`
- [ ] Add the nickname of the external user who found the issue (and/or HackerOne profile) to the Thanks row in the [details section](#details)
### Summary
#### Links
| Description | Link |

View File

@ -1,7 +1,7 @@
Add a description of your merge request here. Merge requests without an adequate
description will not be reviewed until one is added.
## Database Checklist
## Database checklist
When adding migrations:
@ -31,7 +31,7 @@ When removing columns, tables, indexes or other structures:
- [ ] Removed these in a post-deployment migration
- [ ] Made sure the application no longer uses (or ignores) these structures
## General Checklist
## General checklist
- [ ] [Changelog entry](https://docs.gitlab.com/ee/development/changelog.html) added, if necessary
- [ ] [Documentation created/updated](https://docs.gitlab.com/ee/development/doc_styleguide.html)

View File

@ -10,10 +10,6 @@
Capybara/CurrentPathExpectation:
Enabled: false
# Offense count: 956
Capybara/FeatureMethods:
Enabled: false
# Offense count: 23
FactoryBot/DynamicAttributeDefinedStatically:
Exclude:
@ -203,12 +199,6 @@ Naming/HeredocDelimiterCase:
Naming/HeredocDelimiterNaming:
Enabled: false
# Offense count: 27
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect.
Performance/HashEachMethods:
Enabled: false
# Offense count: 1
Performance/UnfreezeString:
Exclude:

View File

@ -2,6 +2,26 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 11.0.3 (2018-07-05)
### Fixed (14 changes, 1 of them is from the community)
- Revert merge request widget button max height. !20175 (George Tsiolis)
- Implement upload copy when moving an issue with upload on object storage. !20191
- Fix broken '!' support to autocomplete MRs in GFM fields. !20204
- Restore showing Elasticsearch and Geo status on dashboard. !20276
- Fix merge request page rendering error when its target/source branch is missing. !20280
- Fix sidebar collapse breapoints for job and wiki pages.
- fix size of code blocks in headings.
- Fix loading screen for search autocomplete dropdown.
- Fix ambiguous due_date column for Issue scopes.
- Always serve favicon from main GitLab domain so that CI badge can be drawn over it.
- Fix tooltip flickering bug.
- Fix refreshing cache keys for open issues count.
- Replace deprecated bs.affix in merge request tabs with sticky polyfill.
- Prevent pipeline job tooltip from scrolling off dropdown container.
## 11.0.2 (2018-06-26)
### Fixed (8 changes, 1 of them is from the community)

View File

@ -50,6 +50,7 @@ _This notice should stay as the first item in the CONTRIBUTING.md file._
- [Definition of done](#definition-of-done)
- [Style guides](#style-guides)
- [Code of conduct](#code-of-conduct)
- [Contribution Flow](#contribution-flow)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@ -225,24 +226,24 @@ Each issue scheduled for the current milestone should be labeled ~Deliverable
or ~"Stretch". Any open issue for a previous milestone should be labeled
~"Next Patch Release", or otherwise rescheduled to a different milestone.
### Bug Priority labels
### Priority labels
Bug Priority labels help us define the time a ~bug fix should be completed. Priority determines how quickly the defect turnaround time must be.
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 + potentially immediate hotfix to GitLab.com | |
| ~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 |
| Label | Meaning | Estimate time to fix |
|-------|-----------------|------------------------------------------------------------------|
| ~P1 | Urgent Priority | The current release + potentially immediate hotfix to GitLab.com |
| ~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) |
### Bug Severity labels
### Severity labels
Severity labels help us clearly communicate the impact of a ~bug on users.
| Label | Meaning | Impact of the defect | Example |
| Label | Meaning | Impact on Functionality | Example |
|-------|-------------------|-------------------------------------------------------|---------|
| ~S1 | Blocker | Outage, broken feature with no workaround | Unable to create an issue. Data corruption/loss. Security breach. |
| ~S2 | Critical Severity | Broken Feature, workaround too complex & unacceptable | Can push commits, but only via the command line. |
@ -251,12 +252,14 @@ Severity labels help us clearly communicate the impact of a ~bug on users.
#### Severity impact guidance
| Label | Security Impact | Availability / Performance Impact |
|-------|---------------------------------------------------------------------|--------------------------------------------------------------|
| ~S1 | >50% users impacted (possible company extinction level event) | |
| ~S2 | Many users or multiple paid customers impacted (but not apocalyptic)| The issue is (almost) guaranteed to occur in the near future |
| ~S3 | A few users or a single paid customer impacted | The issue is likely to occur in the near future |
| ~S4 | No paid users/customer impacted, or expected impact within 30 days | The issue _may_ occur but it's not likely |
Severity levels can be applied further depending on the facet of the impact; e.g. Affected customers, GitLab.com availability, performance and etc. The below is a guideline.
| Severity | Affected Customers/Users | GitLab.com Availability | Performance Degradation |
|----------|---------------------------------------------------------------------|----------------------------------------------------|------------------------------|
| ~S1 | >50% users affected (possible company extinction level event) | Significant impact on all of GitLab.com | |
| ~S2 | Many users or multiple paid customers affected (but not apocalyptic)| Significant impact on large portions of GitLab.com | Degradation is guaranteed to occur in the near future |
| ~S3 | A few users or a single paid customer affected | Limited impact on important portions of GitLab.com | Degradation is likely to occur in the near future |
| ~S4 | No paid users/customer affected, or expected to in the near future | Minor impact on on GitLab.com | Degradation _may_ occur but it's not likely |
### Label for community contributors
@ -729,6 +732,24 @@ reported by emailing `contact@gitlab.com`.
This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant], version 1.1.0,
available at [http://contributor-covenant.org/version/1/1/0/](http://contributor-covenant.org/version/1/1/0/).
## Contribution Flow
When contributing to GitLab, your merge request is subject to review by merge request maintainers of a particular specialty.
When you submit code to GitLab, we really want it to get merged, but there will be times when it will not be merged.
When maintainers are reading through a merge request they may request guidance from other maintainers. If merge request maintainers conclude that the code should not be merged, our reasons will be fully disclosed. If it has been decided that the code quality is not up to GitLabs standards, the merge request maintainer will refer the author to our docs and code style guides, and provide some guidance.
Sometimes style guides will be followed but the code will lack structural integrity, or the maintainer will have reservations about the codes overall quality. When there is a reservation the maintainer will inform the author and provide some guidance. The author may then choose to update the merge request. Once the merge request has been updated and reassigned to the maintainer, they will review the code again. Once the code has been resubmitted any number of times, the maintainer may choose to close the merge request with a summary of why it will not be merged, as well as some guidance. If the merge request is closed the maintainer will be open to discussion as to how to improve the code so it can be approved in the future.
GitLab will do its best to review community contributions as quickly as possible. Specially appointed developers review community contributions daily. You may take a look at the [team page](https://about.gitlab.com/team/) for the merge request coach who specializes in the type of code you have written and mention them in the merge request. For example, if you have written some JavaScript in your code then you should mention the frontend merge request coach. If your code has multiple disciplines you may mention multiple merge request coaches.
GitLab receives a lot of community contributions, so if your code has not been reviewed within 4 days of its initial submission feel free to re-mention the appropriate merge request coach.
When submitting code to GitLab, you may feel that your contribution requires the aid of an external library. If your code includes an external library please provide a link to the library, as well as reasons for including it.
When your code contains more than 500 changes, any major breaking changes, or an external library, `@mention` a maintainer in the merge request. If you are not sure who to mention, the reviewer will add one early in the merge request process.
[core team]: https://about.gitlab.com/core-team/
[team]: https://about.gitlab.com/team/
[getting-help]: https://about.gitlab.com/getting-help/

6
Dangerfile Normal file
View File

@ -0,0 +1,6 @@
danger.import_dangerfile(path: 'danger/metadata')
danger.import_dangerfile(path: 'danger/changes_size')
danger.import_dangerfile(path: 'danger/changelog')
danger.import_dangerfile(path: 'danger/specs')
danger.import_dangerfile(path: 'danger/gemfile')
danger.import_dangerfile(path: 'danger/database')

View File

@ -1 +1 @@
0.109.0
0.112.0

View File

@ -1 +1 @@
0.9.1
1.0.0

View File

@ -1 +1 @@
7.1.4
7.1.5

View File

@ -1 +1 @@
4.3.1
5.0.0

12
Gemfile
View File

@ -47,7 +47,7 @@ gem 'omniauth-google-oauth2', '~> 0.5.3'
gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos
gem 'omniauth-oauth2-generic', '~> 0.2.2'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth-shibboleth', '~> 1.2.0'
gem 'omniauth-shibboleth', '~> 1.3.0'
gem 'omniauth-twitter', '~> 1.4'
gem 'omniauth_crowd', '~> 2.2.0'
gem 'omniauth-authentiq', '~> 0.3.3'
@ -104,7 +104,7 @@ gem 'hashie-forbidden_attributes'
gem 'kaminari', '~> 1.0'
# HAML
gem 'hamlit', '~> 2.6.1'
gem 'hamlit', '~> 2.8.8'
# Files attachments
gem 'carrierwave', '~> 1.2'
@ -132,7 +132,7 @@ gem 'unf', '~> 0.1.4'
gem 'seed-fu', '~> 2.3.7'
# Markdown and HTML processing
gem 'html-pipeline', '~> 2.7.1'
gem 'html-pipeline', '~> 2.8'
gem 'deckar01-task_list', '2.0.0'
gem 'gitlab-markup', '~> 1.6.4'
gem 'redcarpet', '~> 3.4'
@ -351,9 +351,9 @@ group :development, :test do
gem 'spring', '~> 2.0.0'
gem 'spring-commands-rspec', '~> 1.0.4'
gem 'gitlab-styles', '~> 2.3', require: false
gem 'gitlab-styles', '~> 2.4', require: false
# Pin these dependencies, otherwise a new rule could break the CI pipelines
gem 'rubocop', '~> 0.52.1'
gem 'rubocop', '~> 0.54.0'
gem 'rubocop-rspec', '~> 1.22.1'
gem 'scss_lint', '~> 0.56.0', require: false
@ -418,7 +418,7 @@ group :ed25519 do
end
# Gitaly GRPC client
gem 'gitaly-proto', '~> 0.103.0', require: 'gitaly'
gem 'gitaly-proto', '~> 0.105.0', require: 'gitaly'
gem 'grpc', '~> 1.11.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed

View File

@ -108,7 +108,7 @@ GEM
capybara-screenshot (1.0.14)
capybara (>= 1.0, < 3)
launchy
carrierwave (1.2.1)
carrierwave (1.2.3)
activemodel (>= 4.0.0)
activesupport (>= 4.0.0)
mime-types (>= 1.16)
@ -282,7 +282,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gitaly-proto (0.103.0)
gitaly-proto (0.105.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
@ -312,8 +312,8 @@ GEM
mime-types (>= 1.16)
posix-spawn (~> 0.3)
gitlab-markup (1.6.4)
gitlab-styles (2.3.2)
rubocop (~> 0.51)
gitlab-styles (2.4.1)
rubocop (~> 0.54.0)
rubocop-gitlab-security (~> 0.1.0)
rubocop-rspec (~> 1.19)
gitlab_omniauth-ldap (2.0.4)
@ -359,7 +359,7 @@ GEM
grape-entity (0.7.1)
activesupport (>= 4.0)
multi_json (>= 1.3.2)
grape-path-helpers (1.0.5)
grape-path-helpers (1.0.6)
activesupport (>= 4, < 5.1)
grape (~> 1.0)
rake (~> 12)
@ -381,8 +381,8 @@ GEM
rake (>= 10, < 13)
rubocop (>= 0.49.0)
sysexits (~> 1.1)
hamlit (2.6.1)
temple (~> 0.7.6)
hamlit (2.8.8)
temple (>= 0.8.0)
thor
tilt
hashdiff (0.3.4)
@ -394,7 +394,7 @@ GEM
hipchat (1.5.2)
httparty
mimemagic
html-pipeline (2.7.1)
html-pipeline (2.8.3)
activesupport (>= 2)
nokogiri (>= 1.4)
html2text (0.2.0)
@ -568,7 +568,7 @@ GEM
omniauth-saml (1.10.0)
omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.7)
omniauth-shibboleth (1.2.1)
omniauth-shibboleth (1.3.0)
omniauth (>= 1.0.0)
omniauth-twitter (1.4.0)
omniauth-oauth (~> 1.1)
@ -776,16 +776,16 @@ GEM
pg
rails
sqlite3
rubocop (0.52.1)
rubocop (0.54.0)
parallel (~> 1.10)
parser (>= 2.4.0.2, < 3.0)
parser (>= 2.5)
powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
rubocop-gitlab-security (0.1.1)
rubocop (>= 0.51)
rubocop-rspec (1.22.1)
rubocop-rspec (1.22.2)
rubocop (>= 0.52.1)
ruby-enum (0.7.2)
i18n
@ -889,7 +889,7 @@ GEM
sys-filesystem (1.1.6)
ffi
sysexits (1.2.0)
temple (0.7.7)
temple (0.8.0)
test-prof (0.2.5)
test_after_commit (1.1.0)
activerecord (>= 3.2)
@ -900,7 +900,7 @@ GEM
rack (>= 1, < 3)
thor (0.19.4)
thread_safe (0.3.6)
tilt (2.0.6)
tilt (2.0.8)
timecop (0.8.1)
timfel-krb5-auth (0.8.3)
toml (0.1.2)
@ -1037,13 +1037,13 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.103.0)
gitaly-proto (~> 0.105.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
gitlab-gollum-rugged_adapter (~> 0.4.4)
gitlab-markup (~> 1.6.4)
gitlab-styles (~> 2.3)
gitlab-styles (~> 2.4)
gitlab_omniauth-ldap (~> 2.0.4)
gon (~> 6.2)
google-api-client (~> 0.19.8)
@ -1057,11 +1057,11 @@ DEPENDENCIES
graphql (~> 1.8.0)
grpc (~> 1.11.0)
haml_lint (~> 0.26.0)
hamlit (~> 2.6.1)
hamlit (~> 2.8.8)
hashie-forbidden_attributes
health_check (~> 2.6.0)
hipchat (~> 1.5.0)
html-pipeline (~> 2.7.1)
html-pipeline (~> 2.8)
html2text
httparty (~> 0.13.3)
icalendar
@ -1101,7 +1101,7 @@ DEPENDENCIES
omniauth-kerberos (~> 0.3.0)
omniauth-oauth2-generic (~> 0.2.2)
omniauth-saml (~> 1.10)
omniauth-shibboleth (~> 1.2.0)
omniauth-shibboleth (~> 1.3.0)
omniauth-twitter (~> 1.4)
omniauth_crowd (~> 2.2.0)
org-ruby (~> 0.9.12)
@ -1143,7 +1143,7 @@ DEPENDENCIES
rspec-retry (~> 0.4.5)
rspec-set (~> 0.1.3)
rspec_profiling (~> 0.0.5)
rubocop (~> 0.52.1)
rubocop (~> 0.54.0)
rubocop-rspec (~> 1.22.1)
ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.17.0)

View File

@ -79,7 +79,7 @@ GEM
babosa (1.0.2)
base32 (0.3.2)
batch-loader (1.2.1)
bcrypt (3.1.11)
bcrypt (3.1.12)
bcrypt_pbkdf (1.0.0)
benchmark-ips (2.3.0)
better_errors (2.1.1)
@ -111,7 +111,7 @@ GEM
capybara-screenshot (1.0.14)
capybara (>= 1.0, < 3)
launchy
carrierwave (1.2.1)
carrierwave (1.2.3)
activemodel (>= 4.0.0)
activesupport (>= 4.0.0)
mime-types (>= 1.16)
@ -285,7 +285,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gitaly-proto (0.103.0)
gitaly-proto (0.105.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
@ -315,8 +315,8 @@ GEM
mime-types (>= 1.16)
posix-spawn (~> 0.3)
gitlab-markup (1.6.4)
gitlab-styles (2.3.2)
rubocop (~> 0.51)
gitlab-styles (2.4.1)
rubocop (~> 0.54.0)
rubocop-gitlab-security (~> 0.1.0)
rubocop-rspec (~> 1.19)
gitlab_omniauth-ldap (2.0.4)
@ -384,8 +384,8 @@ GEM
rake (>= 10, < 13)
rubocop (>= 0.49.0)
sysexits (~> 1.1)
hamlit (2.6.1)
temple (~> 0.7.6)
hamlit (2.8.8)
temple (>= 0.8.0)
thor
tilt
hashdiff (0.3.4)
@ -397,7 +397,7 @@ GEM
hipchat (1.5.2)
httparty
mimemagic
html-pipeline (2.7.1)
html-pipeline (2.8.3)
activesupport (>= 2)
nokogiri (>= 1.4)
html2text (0.2.0)
@ -514,7 +514,7 @@ GEM
net-ssh (5.0.1)
netrc (0.11.0)
nio4r (2.3.1)
nokogiri (1.8.2)
nokogiri (1.8.3)
mini_portile2 (~> 2.3.0)
nokogumbo (1.5.0)
nokogiri
@ -572,7 +572,7 @@ GEM
omniauth-saml (1.10.0)
omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.7)
omniauth-shibboleth (1.2.1)
omniauth-shibboleth (1.3.0)
omniauth (>= 1.0.0)
omniauth-twitter (1.4.0)
omniauth-oauth (~> 1.1)
@ -785,16 +785,16 @@ GEM
pg
rails
sqlite3
rubocop (0.52.1)
rubocop (0.54.0)
parallel (~> 1.10)
parser (>= 2.4.0.2, < 3.0)
parser (>= 2.5)
powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
rubocop-gitlab-security (0.1.1)
rubocop (>= 0.51)
rubocop-rspec (1.22.1)
rubocop-rspec (1.22.2)
rubocop (>= 0.52.1)
ruby-enum (0.7.2)
i18n
@ -811,7 +811,7 @@ GEM
rubyzip (1.2.1)
rufus-scheduler (3.4.0)
et-orbi (~> 1.0)
rugged (0.27.1)
rugged (0.27.2)
safe_yaml (1.0.4)
sanitize (4.6.5)
crass (~> 1.0.2)
@ -877,7 +877,7 @@ GEM
activesupport (>= 4.2)
spring-commands-rspec (1.0.4)
spring (>= 0.9.1)
sprockets (3.7.1)
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.2.1)
@ -898,7 +898,7 @@ GEM
sys-filesystem (1.1.6)
ffi
sysexits (1.2.0)
temple (0.7.7)
temple (0.8.0)
test-prof (0.2.5)
text (1.3.1)
thin (1.7.0)
@ -1047,13 +1047,13 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.103.0)
gitaly-proto (~> 0.105.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
gitlab-gollum-rugged_adapter (~> 0.4.4)
gitlab-markup (~> 1.6.4)
gitlab-styles (~> 2.3)
gitlab-styles (~> 2.4)
gitlab_omniauth-ldap (~> 2.0.4)
gon (~> 6.2)
google-api-client (~> 0.19.8)
@ -1067,11 +1067,11 @@ DEPENDENCIES
graphql (~> 1.8.0)
grpc (~> 1.11.0)
haml_lint (~> 0.26.0)
hamlit (~> 2.6.1)
hamlit (~> 2.8.8)
hashie-forbidden_attributes
health_check (~> 2.6.0)
hipchat (~> 1.5.0)
html-pipeline (~> 2.7.1)
html-pipeline (~> 2.8)
html2text
httparty (~> 0.13.3)
icalendar
@ -1111,7 +1111,7 @@ DEPENDENCIES
omniauth-kerberos (~> 0.3.0)
omniauth-oauth2-generic (~> 0.2.2)
omniauth-saml (~> 1.10)
omniauth-shibboleth (~> 1.2.0)
omniauth-shibboleth (~> 1.3.0)
omniauth-twitter (~> 1.4)
omniauth_crowd (~> 2.2.0)
org-ruby (~> 0.9.12)
@ -1154,7 +1154,7 @@ DEPENDENCIES
rspec-retry (~> 0.4.5)
rspec-set (~> 0.1.3)
rspec_profiling (~> 0.0.5)
rubocop (~> 0.52.1)
rubocop (~> 0.54.0)
rubocop-rspec (~> 1.22.1)
ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.17.0)

View File

@ -1,11 +1,5 @@
# GitLab
[![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master)
[![Overall test coverage](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg)](https://gitlab.com/gitlab-org/gitlab-ce/pipelines)
[![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq)
[![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42)
[![Gitter](https://badges.gitter.im/gitlabhq/gitlabhq.svg)](https://gitter.im/gitlabhq/gitlabhq?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
## Test coverage
- [![Ruby coverage](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) Ruby
@ -35,7 +29,7 @@ We're hiring developers, support people, and production engineers all the time,
There are two editions of GitLab:
- GitLab Community Edition (CE) is available freely under the MIT Expat license.
- GitLab Enterprise Edition (EE) includes [extra features](https://about.gitlab.com/products/#compare-options) that are more useful for organizations with more than 100 users. To use EE and get official support please [become a subscriber](https://about.gitlab.com/products/).
- GitLab Enterprise Edition (EE) includes [extra features](https://about.gitlab.com/pricing/#compare-options) that are more useful for organizations with more than 100 users. To use EE and get official support please [become a subscriber](https://about.gitlab.com/pricing/).
## Website
@ -120,6 +114,10 @@ All documentation can be found on [docs.gitlab.com/ce/](https://docs.gitlab.com/
Please see [Getting help for GitLab](https://about.gitlab.com/getting-help/) on our website for the many options to get help.
## Why?
[Read here](https://about.gitlab.com/why/)
## Is it any good?
[Yes](https://news.ycombinator.com/item?id=3067434)

View File

@ -2,9 +2,9 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require File.expand_path('../config/application', __FILE__)
require File.expand_path('config/application', __dir__)
relative_url_conf = File.expand_path('../config/initializers/relative_url', __FILE__)
relative_url_conf = File.expand_path('config/initializers/relative_url', __dir__)
require relative_url_conf if File.exist?("#{relative_url_conf}.rb")
Gitlab::Application.load_tasks

View File

@ -100,12 +100,12 @@ const Api = {
},
// Return Merge Request for project
mergeRequest(projectPath, mergeRequestId) {
mergeRequest(projectPath, mergeRequestId, params = {}) {
const url = Api.buildUrl(Api.mergeRequestPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':mrid', mergeRequestId);
return axios.get(url);
return axios.get(url, { params });
},
mergeRequests(params = {}) {

View File

@ -28,23 +28,29 @@ export default {
},
},
methods: {
buildUpdateRequest(list) {
return {
add_label_ids: [list.label.id],
};
},
addIssues() {
const firstListIndex = 1;
const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.id);
const req = this.buildUpdateRequest(list);
// Post the data to the backend
gl.boardService.bulkUpdate(issueIds, {
add_label_ids: [list.label.id],
}).catch(() => {
Flash(__('Failed to update issues, please try again.'));
gl.boardService
.bulkUpdate(issueIds, req)
.catch(() => {
Flash(__('Failed to update issues, please try again.'));
selectedIssues.forEach((issue) => {
list.removeIssue(issue);
list.issuesSize -= 1;
selectedIssues.forEach((issue) => {
list.removeIssue(issue);
list.issuesSize -= 1;
});
});
});
// Add the issues on the frontend
selectedIssues.forEach((issue) => {

View File

@ -121,8 +121,7 @@
<div
v-if="issuesCount > 0 && issues.length === 0"
class="empty-state add-issues-empty-state-filter text-center">
<div
class="svg-content">
<div class="svg-content">
<img :src="emptyStateSvg" />
</div>
<div class="text-content">

View File

@ -5,7 +5,7 @@
const Store = gl.issueBoards.BoardsStore;
export default {
export default Vue.extend({
props: {
issue: {
type: Object,
@ -25,19 +25,16 @@
removeIssue() {
const { issue } = this;
const lists = issue.getLists();
const listLabelIds = lists.map(list => list.label.id);
let labelIds = issue.labels.map(label => label.id).filter(id => !listLabelIds.includes(id));
if (labelIds.length === 0) {
labelIds = [''];
}
const req = this.buildPatchRequest(issue, lists);
const data = {
issue: {
label_ids: labelIds,
},
issue: this.seedPatchRequest(issue, req),
};
if (data.issue.label_ids.length === 0) {
data.issue.label_ids = [''];
}
// Post the remove data
Vue.http.patch(this.updateUrl, data).catch(() => {
Flash(__('Failed to remove issue from board, please try again.'));
@ -54,8 +51,30 @@
Store.detail.issue = {};
},
/**
* Build the default patch request.
*/
buildPatchRequest(issue, lists) {
const listLabelIds = lists.map(list => list.label.id);
const labelIds = issue.labels
.map(label => label.id)
.filter(id => !listLabelIds.includes(id));
return {
label_ids: labelIds,
};
},
/**
* Seed the given patch request.
*
* (This is overridden in EE)
*/
seedPatchRequest(issue, req) {
return req;
},
},
};
});
</script>
<template>
<div

View File

@ -4,6 +4,7 @@
/* global ListAssignee */
import Vue from 'vue';
import '~/vue_shared/models/label';
import IssueProject from './project';
class ListIssue {

View File

@ -7,6 +7,24 @@ import queryData from '../utils/query_data';
const PER_PAGE = 20;
const TYPES = {
backlog: {
isPreset: true,
isExpandable: true,
isBlank: false,
},
closed: {
isPreset: true,
isExpandable: true,
isBlank: false,
},
blank: {
isPreset: true,
isExpandable: false,
isBlank: true,
},
};
class List {
constructor(obj, defaultAvatar) {
this.id = obj.id;
@ -14,8 +32,10 @@ class List {
this.position = obj.position;
this.title = obj.title;
this.type = obj.list_type;
this.preset = ['backlog', 'closed', 'blank'].indexOf(this.type) > -1;
this.isExpandable = ['backlog', 'closed'].indexOf(this.type) > -1;
const typeInfo = this.getTypeInfo(this.type);
this.preset = !!typeInfo.isPreset;
this.isExpandable = !!typeInfo.isExpandable;
this.isExpanded = true;
this.page = 1;
this.loading = true;
@ -31,7 +51,7 @@ class List {
this.title = this.assignee.name;
}
if (this.type !== 'blank' && this.id) {
if (!typeInfo.isBlank && this.id) {
this.getIssues().catch(() => {
// TODO: handle request error
});
@ -107,7 +127,7 @@ class List {
return gl.boardService
.getIssuesForList(this.id, data)
.then(res => res.data)
.then((data) => {
.then(data => {
this.loading = false;
this.issuesSize = data.size;
@ -126,18 +146,7 @@ class List {
return gl.boardService
.newIssue(this.id, issue)
.then(res => res.data)
.then((data) => {
issue.id = data.id;
issue.iid = data.iid;
issue.project = data.project;
issue.path = data.real_path;
issue.referencePath = data.reference_path;
if (this.issuesSize > 1) {
const moveBeforeId = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId);
}
});
.then(data => this.onNewIssueResponse(issue, data));
}
createIssues(data) {
@ -217,6 +226,25 @@ class List {
return !matchesRemove;
});
}
getTypeInfo (type) {
return TYPES[type] || {};
}
onNewIssueResponse (issue, data) {
issue.id = data.id;
issue.iid = data.iid;
issue.project = data.project;
issue.path = data.real_path;
issue.referencePath = data.reference_path;
if (this.issuesSize > 1) {
const moveBeforeId = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId);
}
}
}
window.List = List;
export default List;

View File

@ -2,13 +2,16 @@ import $ from 'jquery';
import { rstrip } from './lib/utils/common_utils';
function openConfirmDangerModal($form, text) {
const $input = $('.js-confirm-danger-input');
$input.val('');
$('.js-confirm-text').text(text || '');
$('.js-confirm-danger-input').val('');
$('#modal-confirm-danger').modal('show');
const confirmTextMatch = $('.js-confirm-danger-match').text();
const $submit = $('.js-confirm-danger-submit');
$submit.disable();
$input.focus();
$('.js-confirm-danger-input').off('input').on('input', function handleInput() {
const confirmText = rstrip($(this).val());

View File

@ -41,11 +41,6 @@ export default {
required: true,
},
},
data() {
return {
activeFile: '',
};
},
computed: {
...mapState({
isLoading: state => state.diffs.isLoading,
@ -63,7 +58,8 @@ export default {
plainDiffPath: state => state.diffs.plainDiffPath,
emailPatchPath: state => state.diffs.emailPatchPath,
}),
...mapGetters(['isParallelView', 'isNotesFetched']),
...mapGetters('diffs', ['isParallelView']),
...mapGetters(['isNotesFetched']),
targetBranch() {
return {
branchName: this.targetBranchName,
@ -115,7 +111,7 @@ export default {
this.adjustView();
},
methods: {
...mapActions(['setBaseConfig', 'fetchDiffFiles']),
...mapActions('diffs', ['setBaseConfig', 'fetchDiffFiles']),
fetchData() {
this.fetchDiffFiles().catch(() => {
createFlash(__('Something went wrong on our end. Please try again!'));
@ -125,14 +121,6 @@ export default {
eventHub.$emit('fetchNotesData');
}
},
setActive(filePath) {
this.activeFile = filePath;
},
unsetActive(filePath) {
if (this.activeFile === filePath) {
this.activeFile = '';
}
},
adjustView() {
if (this.shouldShow && this.isParallelView) {
window.mrTabs.expandViewContainer();
@ -194,7 +182,6 @@ export default {
<changed-files
:diff-files="diffFiles"
:active-file="activeFile"
/>
<div
@ -206,8 +193,6 @@ export default {
:key="file.newPath"
:file="file"
:current-user="currentUser"
@setActive="setActive(file.filePath)"
@unsetActive="unsetActive(file.filePath)"
/>
</div>
<no-changes v-else />

View File

@ -16,13 +16,6 @@ export default {
ClipboardButton,
},
mixins: [changedFilesMixin],
props: {
activeFile: {
type: String,
required: false,
default: '',
},
},
data() {
return {
isStuck: false,
@ -31,7 +24,7 @@ export default {
};
},
computed: {
...mapGetters(['isInlineView', 'isParallelView', 'areAllFilesCollapsed']),
...mapGetters('diffs', ['isInlineView', 'isParallelView', 'areAllFilesCollapsed']),
sumAddedLines() {
return this.sumValues('addedLines');
},
@ -66,11 +59,11 @@ export default {
document.removeEventListener('scroll', this.handleScroll);
},
methods: {
...mapActions(['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']),
...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']),
pluralize,
handleScroll() {
if (!this.updating) {
requestAnimationFrame(this.updateIsStuck);
this.$nextTick(this.updateIsStuck);
this.updating = true;
}
},
@ -148,25 +141,8 @@ export default {
/>
<span
v-show="activeFile"
class="prepend-left-5"
>
<strong class="prepend-right-5">
{{ truncatedDiffPath(activeFile) }}
</strong>
<clipboard-button
:text="activeFile"
:title="s__('Copy file name to clipboard')"
tooltip-placement="bottom"
tooltip-container="body"
class="btn btn-default btn-transparent btn-clipboard"
/>
</span>
<span
v-show="!isStuck"
id="diff-stats"
class="diff-stats-additions-deletions-expanded"
class="js-diff-stats-additions-deletions-expanded
diff-stats-additions-deletions-expanded"
>
with
<strong class="cgreen">
@ -177,6 +153,17 @@ export default {
{{ pluralize(`${sumRemovedLines} deletion`, sumRemovedLines) }}
</strong>
</span>
<div
class="js-diff-stats-additions-deletions-collapsed
diff-stats-additions-deletions-collapsed float-right d-sm-none"
>
<strong class="cgreen">
+{{ sumAddedLines }}
</strong>
<strong class="cred">
-{{ sumRemovedLines }}
</strong>
</div>
</div>
</div>
</div>

View File

@ -40,7 +40,7 @@ export default {
{{ n__('%d changed file', '%d changed files', diffFiles.length) }}
</span>
<icon
:size="8"
class="caret-icon"
name="chevron-down"
/>
</button>

View File

@ -22,7 +22,7 @@ export default {
projectPath: state => state.diffs.projectPath,
endpoint: state => state.diffs.endpoint,
}),
...mapGetters(['isInlineView', 'isParallelView']),
...mapGetters('diffs', ['isInlineView', 'isParallelView']),
diffMode() {
const diffModeKey = Object.keys(diffModes).find(key => this.diffFile[`${key}File`]);
return diffModes[diffModeKey] || diffModes.replaced;
@ -44,7 +44,7 @@ export default {
:diff-lines="diffFile.highlightedDiffLines || []"
/>
<parallel-diff-view
v-else-if="isParallelView"
v-if="isParallelView"
:diff-file="diffFile"
:diff-lines="diffFile.parallelDiffLines || []"
/>

View File

@ -15,9 +15,7 @@ export default {
</script>
<template>
<div
v-if="discussions.length"
>
<div>
<div
v-for="discussion in discussions"
:key="discussion.id"

View File

@ -25,15 +25,11 @@ export default {
},
data() {
return {
isActive: false,
isLoadingCollapsedDiff: false,
forkMessageVisible: false,
};
},
computed: {
isDiscussionsExpanded() {
return true; // TODO: @fatihacet - Fix this.
},
isCollapsed() {
return this.file.collapsed || false;
},
@ -47,15 +43,12 @@ export default {
false,
);
},
},
mounted() {
document.addEventListener('scroll', this.handleScroll);
},
beforeDestroy() {
document.removeEventListener('scroll', this.handleScroll);
showExpandMessage() {
return this.isCollapsed && !this.isLoadingCollapsedDiff && !this.file.tooLarge;
},
},
methods: {
...mapActions(['loadCollapsedDiff']),
...mapActions('diffs', ['loadCollapsedDiff']),
handleToggle() {
const { collapsed, highlightedDiffLines, parallelDiffLines } = this.file;
@ -65,36 +58,6 @@ export default {
this.file.collapsed = !this.file.collapsed;
}
},
handleScroll() {
if (!this.updating) {
requestAnimationFrame(this.scrollUpdate.bind(this));
this.updating = true;
}
},
scrollUpdate() {
const header = document.querySelector('.js-diff-files-changed');
if (!header) {
this.updating = false;
return;
}
const { top, bottom } = this.$el.getBoundingClientRect();
const { top: topOfFixedHeader, bottom: bottomOfFixedHeader } = header.getBoundingClientRect();
const headerOverlapsContent = top < topOfFixedHeader && bottom > bottomOfFixedHeader;
const fullyAboveHeader = bottom < bottomOfFixedHeader;
const fullyBelowHeader = top > topOfFixedHeader;
if (headerOverlapsContent && !this.isActive) {
this.$emit('setActive');
this.isActive = true;
} else if (this.isActive && (fullyAboveHeader || fullyBelowHeader)) {
this.$emit('unsetActive');
this.isActive = false;
}
this.updating = false;
},
handleLoadCollapsedDiff() {
this.isLoadingCollapsedDiff = true;
@ -128,7 +91,6 @@ export default {
:diff-file="file"
:collapsible="true"
:expanded="!isCollapsed"
:discussions-expanded="isDiscussionsExpanded"
:add-merge-request-buttons="true"
class="js-file-title file-title"
@toggleFile="handleToggle"
@ -159,7 +121,7 @@ export default {
</div>
<diff-content
v-show="!isCollapsed"
v-if="!isCollapsed"
:class="{ hidden: isCollapsed || file.tooLarge }"
:diff-file="file"
/>
@ -168,7 +130,7 @@ export default {
class="diff-content loading"
/>
<div
v-show="isCollapsed && !isLoadingCollapsedDiff && !file.tooLarge"
v-if="showExpandMessage"
class="nothing-here-block diff-collapsed"
>
{{ __('This diff is collapsed.') }}

View File

@ -1,7 +1,9 @@
<script>
import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
@ -12,6 +14,7 @@ export default {
ClipboardButton,
EditButton,
Icon,
FileIcon,
},
directives: {
Tooltip,
@ -36,11 +39,6 @@ export default {
required: false,
default: true,
},
discussionsExpanded: {
type: Boolean,
required: false,
default: true,
},
currentUser: {
type: Object,
required: true,
@ -52,6 +50,10 @@ export default {
};
},
computed: {
...mapGetters('diffs', ['diffHasExpandedDiscussions']),
hasExpandedDiscussions() {
return this.diffHasExpandedDiscussions(this.diffFile);
},
icon() {
if (this.diffFile.submodule) {
return 'archive';
@ -86,9 +88,6 @@ export default {
collapseIcon() {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
isDiscussionsExpanded() {
return this.discussionsExpanded && this.expanded;
},
viewFileButtonText() {
const truncatedContentSha = _.escape(truncateSha(this.diffFile.contentSha));
return sprintf(
@ -111,7 +110,8 @@ export default {
},
},
methods: {
handleToggle(e, checkTarget) {
...mapActions('diffs', ['toggleFileDiscussions']),
handleToggleFile(e, checkTarget) {
if (
!checkTarget ||
e.target === this.$refs.header ||
@ -123,6 +123,9 @@ export default {
showForkMessage() {
this.$emit('showForkMessage');
},
handleToggleDiscussions() {
this.toggleFileDiscussions(this.diffFile);
},
},
};
</script>
@ -131,7 +134,7 @@ export default {
<div
ref="header"
class="js-file-title file-title file-title-flex-parent"
@click="handleToggle($event, true)"
@click="handleToggleFile($event, true)"
>
<div class="file-header-content">
<icon
@ -139,18 +142,21 @@ export default {
:name="collapseIcon"
:size="16"
aria-hidden="true"
class="diff-toggle-caret"
class="diff-toggle-caret append-right-5"
@click.stop="handleToggle"
/>
<a
v-once
ref="titleWrapper"
:href="titleLink"
class="append-right-4"
>
<i
:class="`fa-${icon}`"
class="fa fa-fw"
<file-icon
:file-name="filePath"
:size="18"
aria-hidden="true"
></i>
css-classes="js-file-icon append-right-5"
/>
<span v-if="diffFile.renamedFile">
<strong
v-tooltip
@ -211,10 +217,11 @@ export default {
v-if="diffFile.blob && diffFile.blob.readableText"
>
<button
:class="{ active: isDiscussionsExpanded }"
:class="{ active: hasExpandedDiscussions }"
:title="s__('MergeRequests|Toggle comments for this file')"
class="btn js-toggle-diff-comments"
class="js-btn-vue-toggle-comments btn"
type="button"
@click="handleToggleDiscussions"
>
<icon name="comment" />
</button>

View File

@ -108,7 +108,7 @@ export default {
},
},
methods: {
...mapActions(['loadMoreLines', 'showCommentForm']),
...mapActions('diffs', ['loadMoreLines', 'showCommentForm']),
handleCommentButton() {
this.showCommentForm({ lineCode: this.lineCode });
},
@ -189,6 +189,7 @@ export default {
</button>
<a
v-if="lineNumber"
v-once
:data-linenumber="lineNumber"
:href="lineHref"
>

View File

@ -59,7 +59,8 @@ export default {
}
},
methods: {
...mapActions(['cancelCommentForm', 'saveNote', 'fetchDiscussions']),
...mapActions('diffs', ['cancelCommentForm']),
...mapActions(['saveNote', 'refetchDiscussionById']),
handleCancelCommentForm() {
this.autosave.reset();
this.cancelCommentForm({
@ -78,10 +79,10 @@ export default {
});
this.saveNote(postData)
.then(() => {
.then(result => {
const endpoint = this.getNotesDataByProp('discussionsPath');
this.fetchDiscussions(endpoint)
this.refetchDiscussionById({ path: endpoint, discussionId: result.discussion_id })
.then(() => {
this.handleCancelCommentForm();
})

View File

@ -10,6 +10,9 @@ import {
NEW_NO_NEW_LINE_TYPE,
LINE_HOVER_CLASS_NAME,
LINE_UNFOLD_CLASS_NAME,
INLINE_DIFF_VIEW_TYPE,
LINE_POSITION_LEFT,
LINE_POSITION_RIGHT,
} from '../constants';
export default {
@ -25,6 +28,11 @@ export default {
type: Object,
required: true,
},
diffViewType: {
type: String,
required: false,
default: INLINE_DIFF_VIEW_TYPE,
},
showCommentButton: {
type: Boolean,
required: false,
@ -57,13 +65,19 @@ export default {
},
},
computed: {
...mapGetters(['isLoggedIn', 'isInlineView']),
...mapGetters(['isLoggedIn']),
normalizedLine() {
if (this.isInlineView) {
return this.line;
let normalizedLine;
if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) {
normalizedLine = this.line;
} else if (this.linePosition === LINE_POSITION_LEFT) {
normalizedLine = this.line.left;
} else if (this.linePosition === LINE_POSITION_RIGHT) {
normalizedLine = this.line.right;
}
return this.lineType === OLD_LINE_TYPE ? this.line.left : this.line.right;
return normalizedLine;
},
isMatchLine() {
return this.normalizedLine.type === MATCH_LINE_TYPE;
@ -72,10 +86,10 @@ export default {
return this.normalizedLine.type === CONTEXT_LINE_TYPE;
},
isMetaLine() {
const { type } = this.normalizedLine;
return (
this.normalizedLine.type === OLD_NO_NEW_LINE_TYPE ||
this.normalizedLine.type === NEW_NO_NEW_LINE_TYPE ||
this.normalizedLine.type === EMPTY_CELL_TYPE
type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
);
},
classNameMap() {
@ -103,14 +117,6 @@ export default {
<template>
<td
v-if="isContentLine"
:class="lineType"
class="line_content"
v-html="normalizedLine.richText"
>
</td>
<td
v-else
:class="classNameMap"
>
<diff-line-gutter-content

View File

@ -31,22 +31,9 @@ export default {
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
...mapGetters(['discussionsByLineCode']),
isDiscussionExpanded() {
if (!this.discussions.length) {
return false;
}
return this.discussions.every(discussion => discussion.expanded);
},
hasCommentForm() {
return this.diffLineCommentForms[this.line.lineCode];
},
discussions() {
return this.discussionsByLineCode[this.line.lineCode] || [];
},
shouldRender() {
return this.isDiscussionExpanded || this.hasCommentForm;
},
className() {
return this.discussions.length ? '' : 'js-temp-notes-holder';
},
@ -56,7 +43,6 @@ export default {
<template>
<tr
v-if="shouldRender"
:class="className"
class="notes_holder"
>
@ -67,6 +53,7 @@ export default {
<td class="notes_content">
<div class="content">
<diff-discussions
v-if="discussions.length"
:discussions="discussions"
/>
<diff-line-note-form

View File

@ -0,0 +1,105 @@
<script>
import { mapGetters } from 'vuex';
import DiffTableCell from './diff_table_cell.vue';
import {
NEW_LINE_TYPE,
OLD_LINE_TYPE,
CONTEXT_LINE_TYPE,
CONTEXT_LINE_CLASS_NAME,
PARALLEL_DIFF_VIEW_TYPE,
LINE_POSITION_LEFT,
LINE_POSITION_RIGHT,
} from '../constants';
export default {
components: {
DiffTableCell,
},
props: {
diffFile: {
type: Object,
required: true,
},
line: {
type: Object,
required: true,
},
isBottom: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isHover: false,
};
},
computed: {
...mapGetters('diffs', ['isInlineView']),
isContextLine() {
return this.line.type === CONTEXT_LINE_TYPE;
},
classNameMap() {
return {
[this.line.type]: this.line.type,
[CONTEXT_LINE_CLASS_NAME]: this.isContextLine,
[PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView,
};
},
inlineRowId() {
const { lineCode, oldLine, newLine } = this.line;
return lineCode || `${this.diffFile.fileHash}_${oldLine}_${newLine}`;
},
},
created() {
this.newLineType = NEW_LINE_TYPE;
this.oldLineType = OLD_LINE_TYPE;
this.linePositionLeft = LINE_POSITION_LEFT;
this.linePositionRight = LINE_POSITION_RIGHT;
},
methods: {
handleMouseMove(e) {
// To show the comment icon on the gutter we need to know if we hover the line.
// Current table structure doesn't allow us to do this with CSS in both of the diff view types
this.isHover = e.type === 'mouseover';
},
},
};
</script>
<template>
<tr
:id="inlineRowId"
:class="classNameMap"
class="line_holder"
@mouseover="handleMouseMove"
@mouseout="handleMouseMove"
>
<diff-table-cell
:diff-file="diffFile"
:line="line"
:line-type="oldLineType"
:is-bottom="isBottom"
:is-hover="isHover"
:show-comment-button="true"
class="diff-line-num old_line"
/>
<diff-table-cell
:diff-file="diffFile"
:line="line"
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isHover"
class="diff-line-num new_line"
/>
<td
v-once
:class="line.type"
class="line_content"
v-html="line.richText"
>
</td>
</tr>
</template>

View File

@ -1,12 +1,52 @@
<script>
import diffContentMixin from '../mixins/diff_content';
import { mapGetters, mapState } from 'vuex';
import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
import { trimFirstCharOfLineContent } from '../store/utils';
export default {
components: {
inlineDiffCommentRow,
inlineDiffTableRow,
},
props: {
diffFile: {
type: Object,
required: true,
},
diffLines: {
type: Array,
required: true,
},
},
computed: {
...mapGetters('diffs', ['commitId']),
...mapGetters(['discussionsByLineCode']),
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
normalizedDiffLines() {
return this.diffLines.map(line => (line.richText ? trimFirstCharOfLineContent(line) : line));
},
diffLinesLength() {
return this.normalizedDiffLines.length;
},
userColorScheme() {
return window.gon.user_color_scheme;
},
},
methods: {
shouldRenderCommentRow(line) {
if (this.diffLineCommentForms[line.lineCode]) return true;
const lineDiscussions = this.discussionsByLineCode[line.lineCode];
if (lineDiscussions === undefined) {
return false;
}
return lineDiscussions.every(discussion => discussion.expanded);
},
},
mixins: [diffContentMixin],
};
</script>
@ -19,13 +59,14 @@ export default {
<template
v-for="(line, index) in normalizedDiffLines"
>
<diff-table-row
<inline-diff-table-row
:diff-file="diffFile"
:line="line"
:is-bottom="index + 1 === diffLinesLength"
:key="line.lineCode"
/>
<inline-diff-comment-row
v-if="shouldRenderCommentRow(line)"
:diff-file="diffFile"
:diff-lines="normalizedDiffLines"
:line="line"

View File

@ -55,13 +55,6 @@ export default {
hasAnyExpandedDiscussion() {
return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight;
},
shouldRenderDiscussionsRow() {
const hasDiscussion = this.hasDiscussion && this.hasAnyExpandedDiscussion;
const hasCommentFormOnLeft = this.diffLineCommentForms[this.leftLineCode];
const hasCommentFormOnRight = this.diffLineCommentForms[this.rightLineCode];
return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight;
},
shouldRenderDiscussionsOnLeft() {
return this.discussionsByLineCode[this.leftLineCode] && this.hasExpandedDiscussionOnLeft;
},
@ -81,7 +74,6 @@ export default {
<template>
<tr
v-if="shouldRenderDiscussionsRow"
:class="className"
class="notes_holder"
>
@ -92,6 +84,7 @@ export default {
class="content"
>
<diff-discussions
v-if="discussionsByLineCode[leftLineCode].length"
:discussions="discussionsByLineCode[leftLineCode]"
/>
</div>
@ -112,6 +105,7 @@ export default {
class="content"
>
<diff-discussions
v-if="discussionsByLineCode[rightLineCode].length"
:discussions="discussionsByLineCode[rightLineCode]"
/>
</div>

View File

@ -35,30 +35,21 @@ export default {
},
data() {
return {
isHover: false,
isLeftHover: false,
isRightHover: false,
};
},
computed: {
...mapGetters(['isInlineView', 'isParallelView']),
...mapGetters('diffs', ['isParallelView']),
isContextLine() {
return this.line.left
? this.line.left.type === CONTEXT_LINE_TYPE
: this.line.type === CONTEXT_LINE_TYPE;
return this.line.left.type === CONTEXT_LINE_TYPE;
},
classNameMap() {
return {
[this.line.type]: this.line.type,
[CONTEXT_LINE_CLASS_NAME]: this.isContextLine,
[PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView,
};
},
inlineRowId() {
const { lineCode, oldLine, newLine } = this.line;
return lineCode || `${this.diffFile.fileHash}_${oldLine}_${newLine}`;
},
parallelViewLeftLineType() {
if (this.line.right.type === NEW_NO_NEW_LINE_TYPE) {
return OLD_NO_NEW_LINE_TYPE;
@ -72,23 +63,19 @@ export default {
this.oldLineType = OLD_LINE_TYPE;
this.linePositionLeft = LINE_POSITION_LEFT;
this.linePositionRight = LINE_POSITION_RIGHT;
this.parallelDiffViewType = PARALLEL_DIFF_VIEW_TYPE;
},
methods: {
handleMouseMove(e) {
const isHover = e.type === 'mouseover';
const hoveringCell = e.target.closest('td');
const allCellsInHoveringRow = Array.from(e.currentTarget.children);
const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell);
if (this.isInlineView) {
this.isHover = isHover;
if (hoverIndex >= 2) {
this.isRightHover = isHover;
} else {
const hoveringCell = e.target.closest('td');
const allCellsInHoveringRow = Array.from(e.currentTarget.children);
const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell);
if (hoverIndex >= 2) {
this.isRightHover = isHover;
} else {
this.isLeftHover = isHover;
}
this.isLeftHover = isHover;
}
},
// Prevent text selecting on both sides of parallel diff view
@ -110,40 +97,6 @@ export default {
<template>
<tr
v-if="isInlineView"
:id="inlineRowId"
:class="classNameMap"
class="line_holder"
@mouseover="handleMouseMove"
@mouseout="handleMouseMove"
>
<diff-table-cell
:diff-file="diffFile"
:line="line"
:line-type="oldLineType"
:is-bottom="isBottom"
:is-hover="isHover"
:show-comment-button="true"
class="diff-line-num old_line"
/>
<diff-table-cell
:diff-file="diffFile"
:line="line"
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isHover"
class="diff-line-num new_line"
/>
<diff-table-cell
:class="line.type"
:diff-file="diffFile"
:line="line"
:is-content-line="true"
/>
</tr>
<tr
v-else
:class="classNameMap"
class="line_holder"
@mouseover="handleMouseMove"
@ -157,17 +110,18 @@ export default {
:is-bottom="isBottom"
:is-hover="isLeftHover"
:show-comment-button="true"
:diff-view-type="parallelDiffViewType"
class="diff-line-num old_line"
/>
<diff-table-cell
<td
v-once
:id="line.left.lineCode"
:diff-file="diffFile"
:line="line"
:is-content-line="true"
:line-type="parallelViewLeftLineType"
:class="parallelViewLeftLineType"
class="line_content parallel left-side"
@mousedown.native="handleParallelLineMouseDown"
/>
v-html="line.left.richText"
>
</td>
<diff-table-cell
:diff-file="diffFile"
:line="line"
@ -176,16 +130,17 @@ export default {
:is-bottom="isBottom"
:is-hover="isRightHover"
:show-comment-button="true"
:diff-view-type="parallelDiffViewType"
class="diff-line-num new_line"
/>
<diff-table-cell
<td
v-once
:id="line.right.lineCode"
:diff-file="diffFile"
:line="line"
:is-content-line="true"
:line-type="line.right.type"
:class="line.right.type"
class="line_content parallel right-side"
@mousedown.native="handleParallelLineMouseDown"
/>
v-html="line.right.richText"
>
</td>
</tr>
</template>

View File

@ -1,25 +1,82 @@
<script>
import diffContentMixin from '../mixins/diff_content';
import { mapState, mapGetters } from 'vuex';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
import { EMPTY_CELL_TYPE } from '../constants';
import { trimFirstCharOfLineContent } from '../store/utils';
export default {
components: {
parallelDiffTableRow,
parallelDiffCommentRow,
},
mixins: [diffContentMixin],
props: {
diffFile: {
type: Object,
required: true,
},
diffLines: {
type: Array,
required: true,
},
},
computed: {
...mapGetters('diffs', ['commitId']),
...mapGetters(['discussionsByLineCode']),
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
parallelDiffLines() {
return this.normalizedDiffLines.map(line => {
if (!line.left) {
Object.assign(line, { left: { type: EMPTY_CELL_TYPE } });
} else if (!line.right) {
Object.assign(line, { right: { type: EMPTY_CELL_TYPE } });
return this.diffLines.map(line => {
const parallelLine = Object.assign({}, line);
if (line.left) {
parallelLine.left = trimFirstCharOfLineContent(line.left);
} else {
parallelLine.left = { type: EMPTY_CELL_TYPE };
}
return line;
if (line.right) {
parallelLine.right = trimFirstCharOfLineContent(line.right);
} else {
parallelLine.right = { type: EMPTY_CELL_TYPE };
}
return parallelLine;
});
},
diffLinesLength() {
return this.parallelDiffLines.length;
},
userColorScheme() {
return window.gon.user_color_scheme;
},
},
methods: {
shouldRenderCommentRow(line) {
const leftLineCode = line.left.lineCode;
const rightLineCode = line.right.lineCode;
const discussions = this.discussionsByLineCode;
const leftDiscussions = discussions[leftLineCode];
const rightDiscussions = discussions[rightLineCode];
const hasDiscussion = leftDiscussions || rightDiscussions;
const hasExpandedDiscussionOnLeft = leftDiscussions
? leftDiscussions.every(discussion => discussion.expanded)
: false;
const hasExpandedDiscussionOnRight = rightDiscussions
? rightDiscussions.every(discussion => discussion.expanded)
: false;
if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) {
return true;
}
const hasCommentFormOnLeft = this.diffLineCommentForms[leftLineCode];
const hasCommentFormOnRight = this.diffLineCommentForms[rightLineCode];
return hasCommentFormOnLeft || hasCommentFormOnRight;
},
},
};
</script>
@ -35,13 +92,14 @@ export default {
<template
v-for="(line, index) in parallelDiffLines"
>
<diff-table-row
<parallel-diff-table-row
:diff-file="diffFile"
:line="line"
:is-bottom="index + 1 === diffLinesLength"
:key="index"
/>
<parallel-diff-comment-row
v-if="shouldRenderCommentRow(line)"
:key="line.left.lineCode || line.right.lineCode"
:line="line"
:diff-file="diffFile"

View File

@ -1,57 +0,0 @@
import { mapGetters } from 'vuex';
import diffDiscussions from '../components/diff_discussions.vue';
import diffLineGutterContent from '../components/diff_line_gutter_content.vue';
import diffLineNoteForm from '../components/diff_line_note_form.vue';
import diffTableRow from '../components/diff_table_row.vue';
import { trimFirstCharOfLineContent } from '../store/utils';
export default {
props: {
diffFile: {
type: Object,
required: true,
},
diffLines: {
type: Array,
required: true,
},
},
components: {
diffDiscussions,
diffTableRow,
diffLineNoteForm,
diffLineGutterContent,
},
computed: {
...mapGetters(['commit']),
commitId() {
return this.commit && this.commit.id;
},
userColorScheme() {
return window.gon.user_color_scheme;
},
normalizedDiffLines() {
return this.diffLines.map(line => {
if (line.richText) {
return trimFirstCharOfLineContent(line);
}
if (line.left) {
Object.assign(line, { left: trimFirstCharOfLineContent(line.left) });
}
if (line.right) {
Object.assign(line, { right: trimFirstCharOfLineContent(line.right) });
}
return line;
});
},
diffLinesLength() {
return this.normalizedDiffLines.length;
},
fileHash() {
return this.diffFile.fileHash;
},
},
};

View File

@ -82,14 +82,32 @@ export const expandAllFiles = ({ commit }) => {
commit(types.EXPAND_ALL_FILES);
};
export default {
setBaseConfig,
fetchDiffFiles,
setInlineDiffViewType,
setParallelDiffViewType,
showCommentForm,
cancelCommentForm,
loadMoreLines,
loadCollapsedDiff,
expandAllFiles,
/**
* Toggles the file discussions after user clicked on the toggle discussions button.
*
* Gets the discussions for the provided diff.
*
* If all discussions are expanded, it will collapse all of them
* If all discussions are collapsed, it will expand all of them
* If some discussions are open and others closed, it will expand the closed ones.
*
* @param {Object} diff
*/
export const toggleFileDiscussions = ({ getters, dispatch }, diff) => {
const discussions = getters.getDiffFileDiscussions(diff);
const shouldCloseAll = getters.diffHasAllExpandedDiscussions(diff);
const shouldExpandAll = getters.diffHasAllCollpasedDiscussions(diff);
discussions.forEach(discussion => {
const data = { discussionId: discussion.id };
if (shouldCloseAll) {
dispatch('collapseDiscussion', data, { root: true });
} else if (shouldExpandAll || (!shouldCloseAll && !shouldExpandAll && !discussion.expanded)) {
dispatch('expandDiscussion', data, { root: true });
}
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};

View File

@ -1,16 +1,60 @@
import _ from 'underscore';
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants';
export default {
isParallelView(state) {
return state.diffViewType === PARALLEL_DIFF_VIEW_TYPE;
},
isInlineView(state) {
return state.diffViewType === INLINE_DIFF_VIEW_TYPE;
},
areAllFilesCollapsed(state) {
return state.diffFiles.every(file => file.collapsed);
},
commit(state) {
return state.commit;
},
export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW_TYPE;
export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE;
export const areAllFilesCollapsed = state => state.diffFiles.every(file => file.collapsed);
export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null);
/**
* Checks if the diff has all discussions expanded
* @param {Object} diff
* @returns {Boolean}
*/
export const diffHasAllExpandedDiscussions = (state, getters) => diff => {
const discussions = getters.getDiffFileDiscussions(diff);
return (discussions.length && discussions.every(discussion => discussion.expanded)) || false;
};
/**
* Checks if the diff has all discussions collpased
* @param {Object} diff
* @returns {Boolean}
*/
export const diffHasAllCollpasedDiscussions = (state, getters) => diff => {
const discussions = getters.getDiffFileDiscussions(diff);
return (discussions.length && discussions.every(discussion => !discussion.expanded)) || false;
};
/**
* Checks if the diff has any open discussions
* @param {Object} diff
* @returns {Boolean}
*/
export const diffHasExpandedDiscussions = (state, getters) => diff => {
const discussions = getters.getDiffFileDiscussions(diff);
return (
(discussions.length && discussions.find(discussion => discussion.expanded) !== undefined) ||
false
);
};
/**
* Returns an array with the discussions of the given diff
* @param {Object} diff
* @returns {Array}
*/
export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) => diff =>
rootGetters.discussions.filter(
discussion =>
discussion.diff_discussion && _.isEqual(discussion.diff_file.file_hash, diff.fileHash),
) || [];
// prevent babel-plugin-rewire from generating an invalid default during karma∂ tests
export default () => {};

View File

@ -1,11 +0,0 @@
import Vue from 'vue';
import Vuex from 'vuex';
import diffsModule from './modules';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
diffs: diffsModule,
},
});

View File

@ -0,0 +1,18 @@
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default () => ({
isLoading: true,
endpoint: '',
basePath: '',
commit: null,
diffFiles: [],
mergeRequestDiffs: [],
diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
});

View File

@ -1,25 +1,11 @@
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import actions from '../actions';
import getters from '../getters';
import * as actions from '../actions';
import * as getters from '../getters';
import mutations from '../mutations';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
import createState from './diff_state';
export default {
state: {
isLoading: true,
endpoint: '',
basePath: '',
commit: null,
diffFiles: [],
mergeRequestDiffs: [],
diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
},
namespaced: true,
state: createState(),
getters,
actions,
mutations,

View File

@ -1,7 +1,6 @@
export const SET_BASE_CONFIG = 'SET_BASE_CONFIG';
export const SET_LOADING = 'SET_LOADING';
export const SET_DIFF_DATA = 'SET_DIFF_DATA';
export const SET_DIFF_FILES = 'SET_DIFF_FILES';
export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE';
export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS';
export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE';

View File

@ -20,12 +20,6 @@ export default {
});
},
[types.SET_DIFF_FILES](state, diffFiles) {
Object.assign(state, {
diffFiles: convertObjectPropsToCamelCase(diffFiles, { deep: true }),
});
},
[types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) {
Object.assign(state, {
mergeRequestDiffs: convertObjectPropsToCamelCase(mergeRequestDiffs, { deep: true }),
@ -72,15 +66,10 @@ export default {
},
[types.EXPAND_ALL_FILES](state) {
const diffFiles = [];
state.diffFiles.forEach(file => {
diffFiles.push({
...file,
collapsed: false,
});
});
Object.assign(state, { diffFiles });
// eslint-disable-next-line no-param-reassign
state.diffFiles = state.diffFiles.map(file => ({
...file,
collapsed: false,
}));
},
};

View File

@ -155,18 +155,21 @@ export function addContextLines(options) {
}
}
export function trimFirstCharOfLineContent(line) {
if (!line.richText) {
return line;
/**
* Trims the first char of the `richText` property when it's either a space or a diff symbol.
* @param {Object} line
* @returns {Object}
*/
export function trimFirstCharOfLineContent(line = {}) {
const parsedLine = Object.assign({}, line);
if (line.richText) {
const firstChar = parsedLine.richText.charAt(0);
if (firstChar === ' ' || firstChar === '+' || firstChar === '-') {
parsedLine.richText = line.richText.substring(1);
}
}
const firstChar = line.richText.charAt(0);
if (firstChar === ' ' || firstChar === '+' || firstChar === '-') {
Object.assign(line, {
richText: line.richText.substring(1),
});
}
return line;
return parsedLine;
}

View File

@ -171,6 +171,8 @@ export default class DueDateSelectors {
initMilestoneDatePicker() {
$('.datepicker').each(function initPikadayMilestone() {
const $datePicker = $(this);
const datePickerVal = $datePicker.val();
const calendar = new Pikaday({
field: $datePicker.get(0),
theme: 'gitlab-theme animate-picker',
@ -183,7 +185,7 @@ export default class DueDateSelectors {
},
});
calendar.setDate(parsePikadayDate($datePicker.val()));
calendar.setDate(parsePikadayDate(datePickerVal));
$datePicker.data('pikaday', calendar);
});

View File

@ -1,50 +1,50 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
export default {
directives: {
tooltip,
},
components: {
loadingIcon,
Icon,
},
props: {
actions: {
type: Array,
required: false,
default: () => [],
},
components: {
loadingIcon,
Icon,
},
data() {
return {
isLoading: false,
};
},
computed: {
title() {
return 'Deploy to...';
},
props: {
actions: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
isLoading: false,
};
},
computed: {
title() {
return 'Deploy to...';
},
},
methods: {
onClickAction(endpoint) {
this.isLoading = true;
},
methods: {
onClickAction(endpoint) {
this.isLoading = true;
eventHub.$emit('postAction', endpoint);
},
isActionDisabled(action) {
if (action.playable === undefined) {
return false;
}
return !action.playable;
},
eventHub.$emit('postAction', { endpoint });
},
};
isActionDisabled(action) {
if (action.playable === undefined) {
return false;
}
return !action.playable;
},
},
};
</script>
<template>
<div
@ -61,10 +61,7 @@
data-toggle="dropdown"
>
<span>
<icon
:size="12"
name="play"
/>
<icon name="play" />
<i
class="fa fa-caret-down"
aria-hidden="true"
@ -85,10 +82,6 @@
class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)"
>
<icon
:size="12"
name="play"
/>
<span>
{{ action.name }}
</span>

View File

@ -1,30 +1,30 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import { s__ } from '../../locale';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import { s__ } from '../../locale';
/**
* Renders the external url link in environments table.
*/
export default {
components: {
Icon,
/**
* Renders the external url link in environments table.
*/
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
externalUrl: {
type: String,
required: true,
},
directives: {
tooltip,
},
computed: {
title() {
return s__('Environments|Open live environment');
},
props: {
externalUrl: {
type: String,
required: true,
},
},
computed: {
title() {
return s__('Environments|Open');
},
},
};
},
};
</script>
<template>
<a
@ -37,9 +37,6 @@
target="_blank"
rel="noopener noreferrer nofollow"
>
<icon
:size="12"
name="external-link"
/>
<icon name="external-link" />
</a>
</template>

View File

@ -1,429 +1,450 @@
<script>
import Timeago from 'timeago.js';
import _ from 'underscore';
import tooltip from '~/vue_shared/directives/tooltip';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { humanize } from '~/lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback.vue';
import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
import Timeago from 'timeago.js';
import _ from 'underscore';
import tooltip from '~/vue_shared/directives/tooltip';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { humanize } from '~/lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback.vue';
import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
/**
* Envrionment Item Component
*
* Renders a table row for each environment.
*/
const timeagoInstance = new Timeago();
/**
* Envrionment Item Component
*
* Renders a table row for each environment.
*/
const timeagoInstance = new Timeago();
export default {
components: {
UserAvatarLink,
CommitComponent,
ActionsComponent,
ExternalUrlComponent,
StopComponent,
RollbackComponent,
TerminalButtonComponent,
MonitoringButtonComponent,
export default {
components: {
UserAvatarLink,
CommitComponent,
ActionsComponent,
ExternalUrlComponent,
StopComponent,
RollbackComponent,
TerminalButtonComponent,
MonitoringButtonComponent,
},
directives: {
tooltip,
},
props: {
model: {
type: Object,
required: true,
default: () => ({}),
},
directives: {
tooltip,
canCreateDeployment: {
type: Boolean,
required: false,
default: false,
},
props: {
model: {
type: Object,
required: true,
default: () => ({}),
},
canReadEnvironment: {
type: Boolean,
required: false,
default: false,
},
},
canCreateDeployment: {
type: Boolean,
required: false,
default: false,
},
canReadEnvironment: {
type: Boolean,
required: false,
default: false,
},
computed: {
/**
* Verifies if `last_deployment` key exists in the current Envrionment.
* This key is required to render most of the html - this method works has
* an helper.
*
* @returns {Boolean}
*/
hasLastDeploymentKey() {
if (this.model && this.model.last_deployment && !_.isEmpty(this.model.last_deployment)) {
return true;
}
return false;
},
computed: {
/**
* Verifies if `last_deployment` key exists in the current Envrionment.
* This key is required to render most of the html - this method works has
* an helper.
*
* @returns {Boolean}
*/
hasLastDeploymentKey() {
if (this.model &&
this.model.last_deployment &&
!_.isEmpty(this.model.last_deployment)) {
return true;
}
return false;
},
/**
* Verifies is the given environment has manual actions.
* Used to verify if we should render them or nor.
*
* @returns {Boolean|Undefined}
*/
hasManualActions() {
return this.model &&
this.model.last_deployment &&
this.model.last_deployment.manual_actions &&
this.model.last_deployment.manual_actions.length > 0;
},
/**
* Returns the value of the `stop_action?` key provided in the response.
*
* @returns {Boolean}
*/
hasStopAction() {
return this.model && this.model['stop_action?'];
},
/**
* Verifies if the `deployable` key is present in `last_deployment` key.
* Used to verify whether we should or not render the rollback partial.
*
* @returns {Boolean|Undefined}
*/
canRetry() {
return this.model &&
this.hasLastDeploymentKey &&
this.model.last_deployment &&
this.model.last_deployment.deployable;
},
/**
* Verifies if the date to be shown is present.
*
* @returns {Boolean|Undefined}
*/
canShowDate() {
return this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable !== undefined;
},
/**
* Human readable date.
*
* @returns {String}
*/
createdDate() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.created_at) {
return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
}
return '';
},
/**
* Returns the manual actions with the name parsed.
*
* @returns {Array.<Object>|Undefined}
*/
manualActions() {
if (this.hasManualActions) {
return this.model.last_deployment.manual_actions.map((action) => {
const parsedAction = {
name: humanize(action.name),
play_path: action.play_path,
playable: action.playable,
};
return parsedAction;
});
}
return [];
},
/**
* Builds the string used in the user image alt attribute.
*
* @returns {String}
*/
userImageAltDescription() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.user &&
this.model.last_deployment.user.username) {
return `${this.model.last_deployment.user.username}'s avatar'`;
}
return '';
},
/**
* If provided, returns the commit tag.
*
* @returns {String|Undefined}
*/
commitTag() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.tag) {
return this.model.last_deployment.tag;
}
return undefined;
},
/**
* If provided, returns the commit ref.
*
* @returns {Object|Undefined}
*/
commitRef() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.ref) {
return this.model.last_deployment.ref;
}
return undefined;
},
/**
* If provided, returns the commit url.
*
* @returns {String|Undefined}
*/
commitUrl() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.commit_path) {
return this.model.last_deployment.commit.commit_path;
}
return undefined;
},
/**
* If provided, returns the commit short sha.
*
* @returns {String|Undefined}
*/
commitShortSha() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.short_id) {
return this.model.last_deployment.commit.short_id;
}
return undefined;
},
/**
* If provided, returns the commit title.
*
* @returns {String|Undefined}
*/
commitTitle() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.title) {
return this.model.last_deployment.commit.title;
}
return undefined;
},
/**
* If provided, returns the commit tag.
*
* @returns {Object|Undefined}
*/
commitAuthor() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.author) {
return this.model.last_deployment.commit.author;
}
return undefined;
},
/**
* Verifies if the `retry_path` key is present and returns its value.
*
* @returns {String|Undefined}
*/
retryUrl() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.retry_path) {
return this.model.last_deployment.deployable.retry_path;
}
return undefined;
},
/**
* Verifies if the `last?` key is present and returns its value.
*
* @returns {Boolean|Undefined}
*/
isLastDeployment() {
return this.model && this.model.last_deployment &&
this.model.last_deployment['last?'];
},
/**
* Builds the name of the builds needed to display both the name and the id.
*
* @returns {String}
*/
buildName() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable) {
const { deployable } = this.model.last_deployment;
return `${deployable.name} #${deployable.id}`;
}
return '';
},
/**
* Builds the needed string to show the internal id.
*
* @returns {String}
*/
deploymentInternalId() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.iid) {
return `#${this.model.last_deployment.iid}`;
}
return '';
},
/**
* Verifies if the user object is present under last_deployment object.
*
* @returns {Boolean}
*/
deploymentHasUser() {
return this.model &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.user);
},
/**
* Returns the user object nested with the last_deployment object.
* Used to render the template.
*
* @returns {Object}
*/
deploymentUser() {
if (this.model &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.user)) {
return this.model.last_deployment.user;
}
return {};
},
/**
* Verifies if the build name column should be rendered by verifing
* if all the information needed is present
* and if the environment is not a folder.
*
* @returns {Boolean}
*/
shouldRenderBuildName() {
return !this.model.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.deployable);
},
/**
* Verifies the presence of all the keys needed to render the buil_path.
*
* @return {String}
*/
buildPath() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.build_path) {
return this.model.last_deployment.deployable.build_path;
}
return '';
},
/**
* Verifies the presence of all the keys needed to render the external_url.
*
* @return {String}
*/
externalURL() {
if (this.model && this.model.external_url) {
return this.model.external_url;
}
return '';
},
/**
* Verifies if deplyment internal ID should be rendered by verifing
* if all the information needed is present
* and if the environment is not a folder.
*
* @returns {Boolean}
*/
shouldRenderDeploymentID() {
return !this.model.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined;
},
environmentPath() {
if (this.model && this.model.environment_path) {
return this.model.environment_path;
}
return '';
},
monitoringUrl() {
if (this.model && this.model.metrics_path) {
return this.model.metrics_path;
}
return '';
},
displayEnvironmentActions() {
return this.hasManualActions ||
this.externalURL ||
this.monitoringUrl ||
this.hasStopAction ||
this.canRetry;
},
/**
* Verifies is the given environment has manual actions.
* Used to verify if we should render them or nor.
*
* @returns {Boolean|Undefined}
*/
hasManualActions() {
return (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.manual_actions &&
this.model.last_deployment.manual_actions.length > 0
);
},
methods: {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model);
},
/**
* Returns whether the environment can be stopped.
*
* @returns {Boolean}
*/
canStopEnvironment() {
return this.model && this.model.can_stop;
},
};
/**
* Verifies if the `deployable` key is present in `last_deployment` key.
* Used to verify whether we should or not render the rollback partial.
*
* @returns {Boolean|Undefined}
*/
canRetry() {
return (
this.model &&
this.hasLastDeploymentKey &&
this.model.last_deployment &&
this.model.last_deployment.deployable
);
},
/**
* Verifies if the date to be shown is present.
*
* @returns {Boolean|Undefined}
*/
canShowDate() {
return (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable !== undefined
);
},
/**
* Human readable date.
*
* @returns {String}
*/
createdDate() {
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.created_at
) {
return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
}
return '';
},
/**
* Returns the manual actions with the name parsed.
*
* @returns {Array.<Object>|Undefined}
*/
manualActions() {
if (this.hasManualActions) {
return this.model.last_deployment.manual_actions.map(action => {
const parsedAction = {
name: humanize(action.name),
play_path: action.play_path,
playable: action.playable,
};
return parsedAction;
});
}
return [];
},
/**
* Builds the string used in the user image alt attribute.
*
* @returns {String}
*/
userImageAltDescription() {
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.user &&
this.model.last_deployment.user.username
) {
return `${this.model.last_deployment.user.username}'s avatar'`;
}
return '';
},
/**
* If provided, returns the commit tag.
*
* @returns {String|Undefined}
*/
commitTag() {
if (this.model && this.model.last_deployment && this.model.last_deployment.tag) {
return this.model.last_deployment.tag;
}
return undefined;
},
/**
* If provided, returns the commit ref.
*
* @returns {Object|Undefined}
*/
commitRef() {
if (this.model && this.model.last_deployment && this.model.last_deployment.ref) {
return this.model.last_deployment.ref;
}
return undefined;
},
/**
* If provided, returns the commit url.
*
* @returns {String|Undefined}
*/
commitUrl() {
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.commit_path
) {
return this.model.last_deployment.commit.commit_path;
}
return undefined;
},
/**
* If provided, returns the commit short sha.
*
* @returns {String|Undefined}
*/
commitShortSha() {
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.short_id
) {
return this.model.last_deployment.commit.short_id;
}
return undefined;
},
/**
* If provided, returns the commit title.
*
* @returns {String|Undefined}
*/
commitTitle() {
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.title
) {
return this.model.last_deployment.commit.title;
}
return undefined;
},
/**
* If provided, returns the commit tag.
*
* @returns {Object|Undefined}
*/
commitAuthor() {
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.author
) {
return this.model.last_deployment.commit.author;
}
return undefined;
},
/**
* Verifies if the `retry_path` key is present and returns its value.
*
* @returns {String|Undefined}
*/
retryUrl() {
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.retry_path
) {
return this.model.last_deployment.deployable.retry_path;
}
return undefined;
},
/**
* Verifies if the `last?` key is present and returns its value.
*
* @returns {Boolean|Undefined}
*/
isLastDeployment() {
return this.model && this.model.last_deployment && this.model.last_deployment['last?'];
},
/**
* Builds the name of the builds needed to display both the name and the id.
*
* @returns {String}
*/
buildName() {
if (this.model && this.model.last_deployment && this.model.last_deployment.deployable) {
const { deployable } = this.model.last_deployment;
return `${deployable.name} #${deployable.id}`;
}
return '';
},
/**
* Builds the needed string to show the internal id.
*
* @returns {String}
*/
deploymentInternalId() {
if (this.model && this.model.last_deployment && this.model.last_deployment.iid) {
return `#${this.model.last_deployment.iid}`;
}
return '';
},
/**
* Verifies if the user object is present under last_deployment object.
*
* @returns {Boolean}
*/
deploymentHasUser() {
return (
this.model &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.user)
);
},
/**
* Returns the user object nested with the last_deployment object.
* Used to render the template.
*
* @returns {Object}
*/
deploymentUser() {
if (
this.model &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.user)
) {
return this.model.last_deployment.user;
}
return {};
},
/**
* Verifies if the build name column should be rendered by verifing
* if all the information needed is present
* and if the environment is not a folder.
*
* @returns {Boolean}
*/
shouldRenderBuildName() {
return (
!this.model.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.deployable)
);
},
/**
* Verifies the presence of all the keys needed to render the buil_path.
*
* @return {String}
*/
buildPath() {
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.build_path
) {
return this.model.last_deployment.deployable.build_path;
}
return '';
},
/**
* Verifies the presence of all the keys needed to render the external_url.
*
* @return {String}
*/
externalURL() {
if (this.model && this.model.external_url) {
return this.model.external_url;
}
return '';
},
/**
* Verifies if deplyment internal ID should be rendered by verifing
* if all the information needed is present
* and if the environment is not a folder.
*
* @returns {Boolean}
*/
shouldRenderDeploymentID() {
return (
!this.model.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined
);
},
environmentPath() {
if (this.model && this.model.environment_path) {
return this.model.environment_path;
}
return '';
},
monitoringUrl() {
if (this.model && this.model.metrics_path) {
return this.model.metrics_path;
}
return '';
},
displayEnvironmentActions() {
return (
this.hasManualActions ||
this.externalURL ||
this.monitoringUrl ||
this.canStopEnvironment ||
this.canRetry
);
},
},
methods: {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model);
},
},
};
</script>
<template>
<div
@ -580,11 +601,6 @@
class="btn-group table-action-buttons"
role="group">
<actions-component
v-if="hasManualActions && canCreateDeployment"
:actions="manualActions"
/>
<external-url-component
v-if="externalURL && canReadEnvironment"
:external-url="externalURL"
@ -595,21 +611,26 @@
:monitoring-url="monitoringUrl"
/>
<actions-component
v-if="hasManualActions && canCreateDeployment"
:actions="manualActions"
/>
<terminal-button-component
v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"
/>
<stop-component
v-if="hasStopAction && canCreateDeployment"
:stop-url="model.stop_path"
/>
<rollback-component
v-if="canRetry && canCreateDeployment"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
/>
<stop-component
v-if="canStopEnvironment"
:environment="model"
/>
</div>
</div>
</div>

View File

@ -1,29 +1,29 @@
<script>
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
components: {
Icon,
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
monitoringUrl: {
type: String,
required: true,
},
directives: {
tooltip,
},
computed: {
title() {
return 'Monitoring';
},
props: {
monitoringUrl: {
type: String,
required: true,
},
},
computed: {
title() {
return 'Monitoring';
},
},
};
},
};
</script>
<template>
<a
@ -35,9 +35,6 @@
data-container="body"
rel="noopener noreferrer nofollow"
>
<icon
:size="12"
name="chart"
/>
<icon name="chart" />
</a>
</template>

View File

@ -1,56 +1,74 @@
<script>
/**
* Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`.
*
* Makes a post request when the button is clicked.
*/
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
/**
* Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`.
*
* Makes a post request when the button is clicked.
*/
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../event_hub';
import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
components: {
loadingIcon,
},
props: {
retryUrl: {
type: String,
default: '',
},
export default {
components: {
Icon,
LoadingIcon,
},
isLastDeployment: {
type: Boolean,
default: true,
},
},
data() {
return {
isLoading: false,
};
},
methods: {
onClick() {
this.isLoading = true;
directives: {
tooltip,
},
eventHub.$emit('postAction', this.retryUrl);
},
props: {
retryUrl: {
type: String,
default: '',
},
};
isLastDeployment: {
type: Boolean,
default: true,
},
},
data() {
return {
isLoading: false,
};
},
computed: {
title() {
return this.isLastDeployment ? s__('Environments|Re-deploy to environment') : s__('Environments|Rollback environment');
},
},
methods: {
onClick() {
this.isLoading = true;
eventHub.$emit('postAction', { endpoint: this.retryUrl });
},
},
};
</script>
<template>
<button
v-tooltip
:disabled="isLoading"
:title="title"
type="button"
class="btn d-none d-sm-none d-md-block"
@click="onClick"
>
<span v-if="isLastDeployment">
{{ s__("Environments|Re-deploy") }}
</span>
<span v-else>
{{ s__("Environments|Rollback") }}
</span>
<icon
v-if="isLastDeployment"
name="repeat" />
<icon
v-else
name="redo"/>
<loading-icon v-if="isLoading" />
</button>

View File

@ -1,72 +1,78 @@
<script>
/**
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
*/
/**
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
*/
import $ from 'jquery';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
components: {
loadingIcon,
export default {
components: {
Icon,
LoadingButton,
},
directives: {
tooltip,
},
props: {
environment: {
type: Object,
required: true,
},
},
directives: {
tooltip,
data() {
return {
isLoading: false,
};
},
computed: {
title() {
return s__('Environments|Stop environment');
},
},
props: {
stopUrl: {
type: String,
default: '',
},
mounted() {
eventHub.$on('stopEnvironment', this.onStopEnvironment);
},
beforeDestroy() {
eventHub.$off('stopEnvironment', this.onStopEnvironment);
},
methods: {
onClick() {
$(this.$el).tooltip('dispose');
eventHub.$emit('requestStopEnvironment', this.environment);
},
data() {
return {
isLoading: false,
};
onStopEnvironment(environment) {
if (this.environment.id === environment.id) {
this.isLoading = true;
}
},
computed: {
title() {
return 'Stop';
},
},
methods: {
onClick() {
// eslint-disable-next-line no-alert
if (window.confirm('Are you sure you want to stop this environment?')) {
this.isLoading = true;
$(this.$el).tooltip('dispose');
eventHub.$emit('postAction', this.stopUrl);
}
},
},
};
},
};
</script>
<template>
<button
<loading-button
v-tooltip
:disabled="isLoading"
:loading="isLoading"
:title="title"
:aria-label="title"
type="button"
class="btn stop-env-link d-none d-sm-none d-md-block"
container-class="btn btn-danger d-none d-sm-none d-md-block"
data-container="body"
data-toggle="modal"
data-target="#stop-environment-modal"
@click="onClick"
>
<i
class="fa fa-stop stop-env-icon"
aria-hidden="true"
>
</i>
<loading-icon v-if="isLoading" />
</button>
<icon name="stop"/>
</loading-button>
</template>

View File

@ -1,31 +1,31 @@
<script>
/**
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
/**
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
components: {
Icon,
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
terminalPath: {
type: String,
required: false,
default: '',
},
directives: {
tooltip,
},
computed: {
title() {
return 'Terminal';
},
props: {
terminalPath: {
type: String,
required: false,
default: '',
},
},
computed: {
title() {
return 'Terminal';
},
},
};
},
};
</script>
<template>
<a
@ -36,9 +36,6 @@
class="btn terminal-button d-none d-sm-none d-md-block"
data-container="body"
>
<icon
:size="12"
name="terminal"
/>
<icon name="terminal" />
</a>
</template>

View File

@ -5,10 +5,12 @@
import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from './stop_environment_modal.vue';
export default {
components: {
emptyState,
StopEnvironmentModal,
},
mixins: [
@ -90,6 +92,8 @@
</script>
<template>
<div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
<div class="top-area">
<tabs
:tabs="tabs"

View File

@ -0,0 +1,92 @@
<script>
import GlModal from '~/vue_shared/components/gl_modal.vue';
import { s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub';
export default {
id: 'stop-environment-modal',
name: 'StopEnvironmentModal',
components: {
GlModal,
LoadingButton,
},
directives: {
tooltip,
},
props: {
environment: {
type: Object,
required: true,
},
},
computed: {
noStopActionMessage() {
return sprintf(
s__(
`Environments|Note that this action will stop the environment,
but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment
due to no stop environment action being defined
in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`,
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
ciConfigLinkStart:
'<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">',
ciConfigLinkEnd: '</a>',
},
false,
);
},
},
methods: {
onSubmit() {
eventHub.$emit('stopEnvironment', this.environment);
},
},
};
</script>
<template>
<gl-modal
:id="$options.id"
:footer-primary-button-text="s__('Environments|Stop environment')"
footer-primary-button-variant="danger"
@submit="onSubmit"
>
<template slot="header">
<h4
class="modal-title d-flex mw-100"
>
Stopping
<span
v-tooltip
:title="environment.name"
class="text-truncate ml-1 mr-1 flex-fill"
>{{ environment.name }}</span>
?
</h4>
</template>
<p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p>
<div
v-if="!environment.has_stop_action"
class="warning_message"
>
<p v-html="noStopActionMessage"></p>
<a
href="https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment"
target="_blank"
rel="noopener noreferrer"
>{{ s__('Environments|Learn more about stopping environments') }}</a>
</div>
</gl-modal>
</template>

View File

@ -1,12 +1,18 @@
<script>
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
export default {
components: {
StopEnvironmentModal,
},
mixins: [
environmentsMixin,
CIPaginationMixin,
],
props: {
endpoint: {
type: String,
@ -38,6 +44,8 @@
</script>
<template>
<div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
<div
v-if="!isLoading"
class="top-area"

View File

@ -40,6 +40,7 @@ export default {
scope: getParameterByName('scope') || 'available',
page: getParameterByName('page') || '1',
requestData: {},
environmentInStopModal: {},
};
},
@ -85,7 +86,7 @@ export default {
Flash(s__('Environments|An error occurred while fetching the environments.'));
},
postAction(endpoint) {
postAction({ endpoint, errorMessage }) {
if (!this.isMakingRequest) {
this.isLoading = true;
@ -93,7 +94,7 @@ export default {
.then(() => this.fetchEnvironments())
.catch(() => {
this.isLoading = false;
Flash(s__('Environments|An error occurred while making the request.'));
Flash(errorMessage || s__('Environments|An error occurred while making the request.'));
});
}
},
@ -106,6 +107,15 @@ export default {
.catch(this.errorCallback);
},
updateStopModal(environment) {
this.environmentInStopModal = environment;
},
stopEnvironment(environment) {
const endpoint = environment.stop_path;
const errorMessage = s__('Environments|An error occurred while stopping the environment, please try again');
this.postAction({ endpoint, errorMessage });
},
},
computed: {
@ -162,9 +172,13 @@ export default {
});
eventHub.$on('postAction', this.postAction);
eventHub.$on('requestStopEnvironment', this.updateStopModal);
eventHub.$on('stopEnvironment', this.stopEnvironment);
},
beforeDestroyed() {
eventHub.$off('postAction');
beforeDestroy() {
eventHub.$off('postAction', this.postAction);
eventHub.$off('requestStopEnvironment', this.updateStopModal);
eventHub.$off('stopEnvironment', this.stopEnvironment);
},
};

View File

@ -13,7 +13,7 @@ export default class EnvironmentsService {
// eslint-disable-next-line class-methods-use-this
postAction(endpoint) {
return axios.post(endpoint, {}, { emulateJSON: true });
return axios.post(endpoint, {});
}
getFolderContent(folderUrl) {

View File

@ -0,0 +1,122 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AccessorUtilities from '~/lib/utils/accessor';
import eventHub from '../event_hub';
import store from '../store/';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import { isMobile, updateExistingFrequentItem } from '../utils';
import FrequentItemsSearchInput from './frequent_items_search_input.vue';
import FrequentItemsList from './frequent_items_list.vue';
import frequentItemsMixin from './frequent_items_mixin';
export default {
store,
components: {
LoadingIcon,
FrequentItemsSearchInput,
FrequentItemsList,
},
mixins: [frequentItemsMixin],
props: {
currentUserName: {
type: String,
required: true,
},
currentItem: {
type: Object,
required: true,
},
},
computed: {
...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']),
...mapGetters(['hasSearchQuery']),
translations() {
return this.getTranslations(['loadingMessage', 'header']);
},
},
created() {
const { namespace, currentUserName, currentItem } = this;
const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`;
this.setNamespace(namespace);
this.setStorageKey(storageKey);
if (currentItem.id) {
this.logItemAccess(storageKey, currentItem);
}
eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
methods: {
...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']),
dropdownOpenHandler() {
if (this.searchQuery === '' || isMobile()) {
this.fetchFrequentItems();
}
},
logItemAccess(storageKey, item) {
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return false;
}
// Check if there's any frequent items list set
const storedRawItems = localStorage.getItem(storageKey);
const storedFrequentItems = storedRawItems
? JSON.parse(storedRawItems)
: [{ ...item, frequency: 1 }]; // No frequent items list set, set one up.
// Check if item already exists in list
const itemMatchIndex = storedFrequentItems.findIndex(
frequentItem => frequentItem.id === item.id,
);
if (itemMatchIndex > -1) {
storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem(
storedFrequentItems[itemMatchIndex],
item,
);
} else {
if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) {
storedFrequentItems.shift();
}
storedFrequentItems.push({ ...item, frequency: 1 });
}
return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems));
},
},
};
</script>
<template>
<div>
<frequent-items-search-input
:namespace="namespace"
/>
<loading-icon
v-if="isLoadingItems"
:label="translations.loadingMessage"
class="loading-animation prepend-top-20"
size="2"
/>
<div
v-if="!isLoadingItems && !hasSearchQuery"
class="section-header"
>
{{ translations.header }}
</div>
<frequent-items-list
v-if="!isLoadingItems"
:items="items"
:namespace="namespace"
:has-search-query="hasSearchQuery"
:is-fetch-failed="isFetchFailed"
:matcher="searchQuery"
/>
</div>
</template>

View File

@ -0,0 +1,78 @@
<script>
import FrequentItemsListItem from './frequent_items_list_item.vue';
import frequentItemsMixin from './frequent_items_mixin';
export default {
components: {
FrequentItemsListItem,
},
mixins: [frequentItemsMixin],
props: {
items: {
type: Array,
required: true,
},
hasSearchQuery: {
type: Boolean,
required: true,
},
isFetchFailed: {
type: Boolean,
required: true,
},
matcher: {
type: String,
required: true,
},
},
computed: {
translations() {
return this.getTranslations([
'itemListEmptyMessage',
'itemListErrorMessage',
'searchListEmptyMessage',
'searchListErrorMessage',
]);
},
isListEmpty() {
return this.items.length === 0;
},
listEmptyMessage() {
if (this.hasSearchQuery) {
return this.isFetchFailed
? this.translations.searchListErrorMessage
: this.translations.searchListEmptyMessage;
}
return this.isFetchFailed
? this.translations.itemListErrorMessage
: this.translations.itemListEmptyMessage;
},
},
};
</script>
<template>
<div class="frequent-items-list-container">
<ul class="list-unstyled">
<li
v-if="isListEmpty"
:class="{ 'section-failure': isFetchFailed }"
class="section-empty"
>
{{ listEmptyMessage }}
</li>
<frequent-items-list-item
v-for="item in items"
v-else
:key="item.id"
:item-id="item.id"
:item-name="item.name"
:namespace="item.namespace"
:web-url="item.webUrl"
:avatar-url="item.avatarUrl"
:matcher="matcher"
/>
</ul>
</div>
</template>

View File

@ -0,0 +1,117 @@
<script>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import Identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
Identicon,
},
props: {
matcher: {
type: String,
required: false,
},
itemId: {
type: Number,
required: true,
},
itemName: {
type: String,
required: true,
},
namespace: {
type: String,
required: false,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedItemName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.itemName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.itemName;
},
/**
* Smartly truncates item namespace by doing two things;
* 1. Only include Group names in path by removing item name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of item name from namespace) can be
* done from backend but doing so involves migration of
* existing item namespaces which is not wise thing to do.
*/
truncatedNamespace() {
if (!this.namespace) {
return null;
}
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
},
},
};
</script>
<template>
<li class="frequent-items-list-item-container">
<a
:href="webUrl"
class="clearfix"
>
<div class="frequent-items-item-avatar-container">
<img
v-if="hasAvatar"
:src="avatarUrl"
class="avatar s32"
/>
<identicon
v-else
:entity-id="itemId"
:entity-name="itemName"
size-class="s32"
/>
</div>
<div class="frequent-items-item-metadata-container">
<div
:title="itemName"
class="frequent-items-item-title"
v-html="highlightedItemName"
>
</div>
<div
v-if="truncatedNamespace"
:title="namespace"
class="frequent-items-item-namespace"
>
{{ truncatedNamespace }}
</div>
</div>
</a>
</li>
</template>

View File

@ -0,0 +1,23 @@
import { TRANSLATION_KEYS } from '../constants';
export default {
props: {
namespace: {
type: String,
required: true,
},
},
methods: {
getTranslations(keys) {
const translationStrings = keys.reduce(
(acc, key) => ({
...acc,
[key]: TRANSLATION_KEYS[this.namespace][key],
}),
{},
);
return translationStrings;
},
},
};

View File

@ -0,0 +1,55 @@
<script>
import _ from 'underscore';
import { mapActions } from 'vuex';
import eventHub from '../event_hub';
import frequentItemsMixin from './frequent_items_mixin';
export default {
mixins: [frequentItemsMixin],
data() {
return {
searchQuery: '',
};
},
computed: {
translations() {
return this.getTranslations(['searchInputPlaceholder']);
},
},
watch: {
searchQuery: _.debounce(function debounceSearchQuery() {
this.setSearchQuery(this.searchQuery);
}, 500),
},
mounted() {
eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus);
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus);
},
methods: {
...mapActions(['setSearchQuery']),
setFocus() {
this.$refs.search.focus();
},
},
};
</script>
<template>
<div class="search-input-container d-none d-sm-block">
<input
ref="search"
v-model="searchQuery"
:placeholder="translations.searchInputPlaceholder"
type="search"
class="form-control"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
>
</i>
</div>
</template>

View File

@ -0,0 +1,38 @@
import { s__ } from '~/locale';
export const FREQUENT_ITEMS = {
MAX_COUNT: 20,
LIST_COUNT_DESKTOP: 5,
LIST_COUNT_MOBILE: 3,
ELIGIBLE_FREQUENCY: 3,
};
export const HOUR_IN_MS = 3600000;
export const STORAGE_KEY = {
projects: 'frequent-projects',
groups: 'frequent-groups',
};
export const TRANSLATION_KEYS = {
projects: {
loadingMessage: s__('ProjectsDropdown|Loading projects'),
header: s__('ProjectsDropdown|Frequently visited'),
itemListErrorMessage: s__(
'ProjectsDropdown|This feature requires browser localStorage support',
),
itemListEmptyMessage: s__('ProjectsDropdown|Projects you visit often will appear here'),
searchListErrorMessage: s__('ProjectsDropdown|Something went wrong on our end.'),
searchListEmptyMessage: s__('ProjectsDropdown|Sorry, no projects matched your search'),
searchInputPlaceholder: s__('ProjectsDropdown|Search your projects'),
},
groups: {
loadingMessage: s__('GroupsDropdown|Loading groups'),
header: s__('GroupsDropdown|Frequently visited'),
itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'),
itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'),
searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'),
searchListEmptyMessage: s__('GroupsDropdown|Sorry, no groups matched your search'),
searchInputPlaceholder: s__('GroupsDropdown|Search your groups'),
},
};

View File

@ -0,0 +1,69 @@
import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import eventHub from '~/frequent_items/event_hub';
import frequentItems from './components/app.vue';
Vue.use(Translate);
const frequentItemDropdowns = [
{
namespace: 'projects',
key: 'project',
},
{
namespace: 'groups',
key: 'group',
},
];
document.addEventListener('DOMContentLoaded', () => {
frequentItemDropdowns.forEach(dropdown => {
const { namespace, key } = dropdown;
const el = document.getElementById(`js-${namespace}-dropdown`);
const navEl = document.getElementById(`nav-${namespace}-dropdown`);
// Don't do anything if element doesn't exist (No groups dropdown)
// This is for when the user accesses GitLab without logging in
if (!el || !navEl) {
return;
}
$(navEl).on('shown.bs.dropdown', () => {
eventHub.$emit(`${namespace}-dropdownOpen`);
});
// eslint-disable-next-line no-new
new Vue({
el,
components: {
frequentItems,
},
data() {
const { dataset } = this.$options.el;
const item = {
id: Number(dataset[`${key}Id`]),
name: dataset[`${key}Name`],
namespace: dataset[`${key}Namespace`],
webUrl: dataset[`${key}WebUrl`],
avatarUrl: dataset[`${key}AvatarUrl`] || null,
lastAccessedOn: Date.now(),
};
return {
currentUserName: dataset.userName,
currentItem: item,
};
},
render(createElement) {
return createElement('frequent-items', {
props: {
namespace,
currentUserName: this.currentUserName,
currentItem: this.currentItem,
},
});
},
});
});
});

View File

@ -0,0 +1,81 @@
import Api from '~/api';
import AccessorUtilities from '~/lib/utils/accessor';
import * as types from './mutation_types';
import { getTopFrequentItems } from '../utils';
export const setNamespace = ({ commit }, namespace) => {
commit(types.SET_NAMESPACE, namespace);
};
export const setStorageKey = ({ commit }, key) => {
commit(types.SET_STORAGE_KEY, key);
};
export const requestFrequentItems = ({ commit }) => {
commit(types.REQUEST_FREQUENT_ITEMS);
};
export const receiveFrequentItemsSuccess = ({ commit }, data) => {
commit(types.RECEIVE_FREQUENT_ITEMS_SUCCESS, data);
};
export const receiveFrequentItemsError = ({ commit }) => {
commit(types.RECEIVE_FREQUENT_ITEMS_ERROR);
};
export const fetchFrequentItems = ({ state, dispatch }) => {
dispatch('requestFrequentItems');
if (AccessorUtilities.isLocalStorageAccessSafe()) {
const storedFrequentItems = JSON.parse(localStorage.getItem(state.storageKey));
dispatch(
'receiveFrequentItemsSuccess',
!storedFrequentItems ? [] : getTopFrequentItems(storedFrequentItems),
);
} else {
dispatch('receiveFrequentItemsError');
}
};
export const requestSearchedItems = ({ commit }) => {
commit(types.REQUEST_SEARCHED_ITEMS);
};
export const receiveSearchedItemsSuccess = ({ commit }, data) => {
commit(types.RECEIVE_SEARCHED_ITEMS_SUCCESS, data);
};
export const receiveSearchedItemsError = ({ commit }) => {
commit(types.RECEIVE_SEARCHED_ITEMS_ERROR);
};
export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => {
dispatch('requestSearchedItems');
const params = {
simple: true,
per_page: 20,
membership: !!gon.current_user_id,
};
if (state.namespace === 'projects') {
params.order_by = 'last_activity_at';
}
return Api[state.namespace](searchQuery, params)
.then(results => {
dispatch('receiveSearchedItemsSuccess', results);
})
.catch(() => {
dispatch('receiveSearchedItemsError');
});
};
export const setSearchQuery = ({ commit, dispatch }, query) => {
commit(types.SET_SEARCH_QUERY, query);
if (query) {
dispatch('fetchSearchedItems', query);
} else {
dispatch('fetchFrequentItems');
}
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};

View File

@ -0,0 +1,4 @@
export const hasSearchQuery = state => state.searchQuery !== '';
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};

View File

@ -0,0 +1,16 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
getters,
mutations,
state: state(),
});

View File

@ -0,0 +1,9 @@
export const SET_NAMESPACE = 'SET_NAMESPACE';
export const SET_STORAGE_KEY = 'SET_STORAGE_KEY';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS';
export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS';
export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR';
export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS';
export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS';
export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR';

View File

@ -0,0 +1,71 @@
import * as types from './mutation_types';
export default {
[types.SET_NAMESPACE](state, namespace) {
Object.assign(state, {
namespace,
});
},
[types.SET_STORAGE_KEY](state, storageKey) {
Object.assign(state, {
storageKey,
});
},
[types.SET_SEARCH_QUERY](state, searchQuery) {
const hasSearchQuery = searchQuery !== '';
Object.assign(state, {
searchQuery,
isLoadingItems: true,
hasSearchQuery,
});
},
[types.REQUEST_FREQUENT_ITEMS](state) {
Object.assign(state, {
isLoadingItems: true,
hasSearchQuery: false,
});
},
[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](state, rawItems) {
Object.assign(state, {
items: rawItems,
isLoadingItems: false,
hasSearchQuery: false,
isFetchFailed: false,
});
},
[types.RECEIVE_FREQUENT_ITEMS_ERROR](state) {
Object.assign(state, {
isLoadingItems: false,
hasSearchQuery: false,
isFetchFailed: true,
});
},
[types.REQUEST_SEARCHED_ITEMS](state) {
Object.assign(state, {
isLoadingItems: true,
hasSearchQuery: true,
});
},
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) {
Object.assign(state, {
items: rawItems.map(rawItem => ({
id: rawItem.id,
name: rawItem.name,
namespace: rawItem.name_with_namespace || rawItem.full_name,
webUrl: rawItem.web_url,
avatarUrl: rawItem.avatar_url,
})),
isLoadingItems: false,
hasSearchQuery: true,
isFetchFailed: false,
});
},
[types.RECEIVE_SEARCHED_ITEMS_ERROR](state) {
Object.assign(state, {
isLoadingItems: false,
hasSearchQuery: true,
isFetchFailed: true,
});
},
};

View File

@ -0,0 +1,8 @@
export default () => ({
namespace: '',
storageKey: '',
searchQuery: '',
isLoadingItems: false,
isFetchFailed: false,
items: [],
});

View File

@ -0,0 +1,49 @@
import _ from 'underscore';
import bp from '~/breakpoints';
import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants';
export const isMobile = () => {
const screenSize = bp.getBreakpointSize();
return screenSize === 'sm' || screenSize === 'xs';
};
export const getTopFrequentItems = items => {
if (!items) {
return [];
}
const frequentItemsCount = isMobile()
? FREQUENT_ITEMS.LIST_COUNT_MOBILE
: FREQUENT_ITEMS.LIST_COUNT_DESKTOP;
const frequentItems = items.filter(item => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY);
if (!frequentItems || frequentItems.length === 0) {
return [];
}
frequentItems.sort((itemA, itemB) => {
// Sort all frequent items in decending order of frequency
// and then by lastAccessedOn with recent most first
if (itemA.frequency !== itemB.frequency) {
return itemB.frequency - itemA.frequency;
} else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) {
return itemB.lastAccessedOn - itemA.lastAccessedOn;
}
return 0;
});
return _.first(frequentItems, frequentItemsCount);
};
export const updateExistingFrequentItem = (frequentItem, item) => {
const accessedOverHourAgo =
Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / HOUR_IN_MS > 1;
return {
...item,
frequency: accessedOverHourAgo ? frequentItem.frequency + 1 : frequentItem.frequency,
lastAccessedOn: accessedOverHourAgo ? Date.now() : frequentItem.lastAccessedOn,
};
};

View File

@ -1,6 +1,7 @@
<script>
import Mousetrap from 'mousetrap';
import { mapActions, mapState, mapGetters } from 'vuex';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue';
@ -13,6 +14,7 @@ const originalStopCallback = Mousetrap.stopCallback;
export default {
components: {
NewModal,
IdeSidebar,
RepoTabs,
IdeStatusBar,
@ -137,5 +139,6 @@ export default {
/>
</div>
<ide-status-bar :file="activeFile"/>
<new-modal />
</article>
</template>

View File

@ -5,6 +5,7 @@ import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import CiIcon from '../../vue_shared/components/ci_icon.vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import { rightSidebarViews } from '../constants';
export default {
components: {
@ -49,6 +50,7 @@ export default {
this.stopPipelinePolling();
},
methods: {
...mapActions(['setRightPane']),
...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']),
startTimer() {
this.intervalId = setInterval(() => {
@ -69,24 +71,31 @@ export default {
return `${this.currentProject.web_url}/commit/${shortSha}`;
},
},
rightSidebarViews,
};
</script>
<template>
<footer class="ide-status-bar">
<div
v-if="lastCommit && lastCommitFormatedAge"
v-if="lastCommit"
class="ide-status-branch"
>
<span
v-if="latestPipeline && latestPipeline.details"
class="ide-status-pipeline"
>
<ci-icon
v-tooltip
:status="latestPipeline.details.status"
:title="latestPipeline.details.status.text"
/>
<button
type="button"
class="p-0 border-0 h-50"
@click="setRightPane($options.rightSidebarViews.pipelines)"
>
<ci-icon
v-tooltip
:status="latestPipeline.details.status"
:title="latestPipeline.details.status.text"
/>
</button>
Pipeline
<a
:href="latestPipeline.details.status.details_path"

View File

@ -1,12 +1,16 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import NewDropdown from './new_dropdown/index.vue';
import Icon from '~/vue_shared/components/icon.vue';
import IdeTreeList from './ide_tree_list.vue';
import Upload from './new_dropdown/upload.vue';
import NewEntryButton from './new_dropdown/button.vue';
export default {
components: {
NewDropdown,
Icon,
Upload,
IdeTreeList,
NewEntryButton,
},
computed: {
...mapState(['currentBranchId']),
@ -20,23 +24,42 @@ export default {
}
},
methods: {
...mapActions(['updateViewer']),
...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry']),
},
};
</script>
<template>
<ide-tree-list
header-class="d-flex w-100"
viewer-type="editor"
>
<template
slot="header"
>
{{ __('Edit') }}
<new-dropdown
:project-id="currentProject.name_with_namespace"
:branch="currentBranchId"
/>
<div class="ml-auto d-flex">
<new-entry-button
:label="__('New file')"
:show-label="false"
class="d-flex border-0 p-0 mr-3"
icon="doc-new"
@click="openNewEntryModal({ type: 'blob' })"
/>
<upload
:show-label="false"
class="d-flex mr-3"
button-css-classes="border-0 p-0"
@create="createTempEntry"
/>
<new-entry-button
:label="__('New directory')"
:show-label="false"
class="d-flex border-0 p-0"
icon="folder-new"
@click="openNewEntryModal({ type: 'tree' })"
/>
</div>
</template>
</ide-tree-list>
</template>

View File

@ -0,0 +1,43 @@
<script>
import { mapGetters } from 'vuex';
import Icon from '../../../vue_shared/components/icon.vue';
import TitleComponent from '../../../issue_show/components/title.vue';
import DescriptionComponent from '../../../issue_show/components/description.vue';
export default {
components: {
Icon,
TitleComponent,
DescriptionComponent,
},
computed: {
...mapGetters(['currentMergeRequest']),
},
};
</script>
<template>
<div class="ide-merge-request-info h-100 d-flex flex-column">
<div class="detail-page-header">
<icon
name="git-merge"
class="align-self-center append-right-8"
/>
<strong>
!{{ currentMergeRequest.iid }}
</strong>
</div>
<div class="issuable-details">
<title-component
:issuable-ref="currentMergeRequest.iid"
:title-html="currentMergeRequest.title_html"
:title-text="currentMergeRequest.title"
/>
<description-component
:description-html="currentMergeRequest.description_html"
:description-text="currentMergeRequest.description"
:can-update="false"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,51 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
label: {
type: String,
required: false,
default: null,
},
icon: {
type: String,
required: true,
},
iconClasses: {
type: String,
required: false,
default: null,
},
showLabel: {
type: Boolean,
required: false,
default: true,
},
},
methods: {
clicked() {
this.$emit('click');
},
},
};
</script>
<template>
<button
:aria-label="label"
type="button"
@click.stop.prevent="clicked"
>
<icon
:name="icon"
:css-classes="iconClasses"
/>
<template v-if="showLabel">
{{ label }}
</template>
</button>
</template>

View File

@ -3,12 +3,14 @@ import { mapActions } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
import newModal from './modal.vue';
import upload from './upload.vue';
import ItemButton from './button.vue';
export default {
components: {
icon,
newModal,
upload,
ItemButton,
},
props: {
branch: {
@ -20,11 +22,13 @@ export default {
required: false,
default: '',
},
mouseOver: {
type: Boolean,
required: true,
},
},
data() {
return {
openModal: false,
modalType: '',
dropdownOpen: false,
};
},
@ -34,17 +38,18 @@ export default {
this.$refs.dropdownMenu.scrollIntoView();
});
},
mouseOver() {
if (!this.mouseOver) {
this.dropdownOpen = false;
}
},
},
methods: {
...mapActions(['createTempEntry']),
...mapActions(['createTempEntry', 'openNewEntryModal']),
createNewItem(type) {
this.modalType = type;
this.openModal = true;
this.openNewEntryModal({ type, path: this.path });
this.dropdownOpen = false;
},
hideModal() {
this.openModal = false;
},
openDropdown() {
this.dropdownOpen = !this.dropdownOpen;
},
@ -58,23 +63,19 @@ export default {
:class="{
show: dropdownOpen,
}"
class="dropdown"
class="dropdown d-flex"
>
<button
:aria-label="__('Create new file or directory')"
type="button"
class="btn btn-sm btn-default dropdown-toggle add-to-tree"
aria-label="Create new file or directory"
class="rounded border-0 d-flex ide-entry-dropdown-toggle"
@click.stop="openDropdown()"
>
<icon
:size="12"
name="plus"
css-classes="float-left"
name="hamburger"
/>
<icon
:size="12"
name="arrow-down"
css-classes="float-left"
/>
</button>
<ul
@ -82,39 +83,30 @@ export default {
class="dropdown-menu dropdown-menu-right"
>
<li>
<a
href="#"
role="button"
@click.stop.prevent="createNewItem('blob')"
>
{{ __('New file') }}
</a>
<item-button
:label="__('New file')"
class="d-flex"
icon="doc-new"
icon-classes="mr-2"
@click="createNewItem('blob')"
/>
</li>
<li>
<upload
:branch-id="branch"
:path="path"
@create="createTempEntry"
/>
</li>
<li>
<a
href="#"
role="button"
@click.stop.prevent="createNewItem('tree')"
>
{{ __('New directory') }}
</a>
<item-button
:label="__('New directory')"
class="d-flex"
icon="folder-new"
icon-classes="mr-2"
@click="createNewItem('tree')"
/>
</li>
</ul>
</div>
<new-modal
v-if="openModal"
:type="modalType"
:branch-id="branch"
:path="path"
@hide="hideModal"
@create="createTempEntry"
/>
</div>
</template>

View File

@ -1,78 +1,70 @@
<script>
import { __ } from '~/locale';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { mapActions, mapState } from 'vuex';
import GlModal from '~/vue_shared/components/gl_modal.vue';
export default {
components: {
DeprecatedModal,
},
props: {
branchId: {
type: String,
required: true,
},
type: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
GlModal,
},
data() {
return {
entryName: this.path !== '' ? `${this.path}/` : '',
name: '',
};
},
computed: {
...mapState(['newEntryModal']),
entryName: {
get() {
return this.name || (this.newEntryModal.path !== '' ? `${this.newEntryModal.path}/` : '');
},
set(val) {
this.name = val;
},
},
modalTitle() {
if (this.type === 'tree') {
if (this.newEntryModal.type === 'tree') {
return __('Create new directory');
}
return __('Create new file');
},
buttonLabel() {
if (this.type === 'tree') {
if (this.newEntryModal.type === 'tree') {
return __('Create directory');
}
return __('Create file');
},
},
mounted() {
this.$refs.fieldName.focus();
},
methods: {
...mapActions(['createTempEntry']),
createEntryInStore() {
this.$emit('create', {
branchId: this.branchId,
name: this.entryName,
type: this.type,
this.createTempEntry({
name: this.name,
type: this.newEntryModal.type,
});
this.hideModal();
},
hideModal() {
this.$emit('hide');
focusInput() {
setTimeout(() => {
this.$refs.fieldName.focus();
});
},
},
};
</script>
<template>
<deprecated-modal
:title="modalTitle"
:primary-button-label="buttonLabel"
kind="success"
@cancel="hideModal"
<gl-modal
id="ide-new-entry"
:header-title-text="modalTitle"
:footer-primary-button-text="buttonLabel"
footer-primary-button-variant="success"
@submit="createEntryInStore"
@open="focusInput"
>
<form
slot="body"
<div
class="form-group row"
@submit.prevent="createEntryInStore"
>
<label class="label-light col-form-label col-sm-3">
{{ __('Name') }}
@ -85,6 +77,6 @@ export default {
class="form-control"
/>
</div>
</form>
</deprecated-modal>
</div>
</gl-modal>
</template>

View File

@ -1,71 +1,85 @@
<script>
export default {
props: {
branchId: {
type: String,
required: true,
},
path: {
type: String,
required: false,
default: '',
},
},
mounted() {
this.$refs.fileUpload.addEventListener('change', this.openFile);
},
beforeDestroy() {
this.$refs.fileUpload.removeEventListener('change', this.openFile);
},
methods: {
createFile(target, file, isText) {
const { name } = file;
let { result } = target;
import Icon from '~/vue_shared/components/icon.vue';
import ItemButton from './button.vue';
if (!isText) {
// eslint-disable-next-line prefer-destructuring
result = result.split('base64,')[1];
}
this.$emit('create', {
name: `${(this.path ? `${this.path}/` : '')}${name}`,
branchId: this.branchId,
type: 'blob',
content: result,
base64: !isText,
});
},
readFile(file) {
const reader = new FileReader();
const isText = file.type.match(/text.*/) !== null;
reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true });
if (isText) {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
},
openFile() {
Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file));
},
startFileUpload() {
this.$refs.fileUpload.click();
},
export default {
components: {
Icon,
ItemButton,
},
props: {
path: {
type: String,
required: false,
default: '',
},
};
showLabel: {
type: Boolean,
required: false,
default: true,
},
buttonCssClasses: {
type: String,
required: false,
default: null,
},
},
mounted() {
this.$refs.fileUpload.addEventListener('change', this.openFile);
},
beforeDestroy() {
this.$refs.fileUpload.removeEventListener('change', this.openFile);
},
methods: {
createFile(target, file, isText) {
const { name } = file;
let { result } = target;
if (!isText) {
// eslint-disable-next-line prefer-destructuring
result = result.split('base64,')[1];
}
this.$emit('create', {
name: `${this.path ? `${this.path}/` : ''}${name}`,
type: 'blob',
content: result,
base64: !isText,
});
},
readFile(file) {
const reader = new FileReader();
const isText = file.type.match(/text.*/) !== null;
reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true });
if (isText) {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
},
openFile() {
Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file));
},
startFileUpload() {
this.$refs.fileUpload.click();
},
},
};
</script>
<template>
<div>
<a
href="#"
role="button"
@click.stop.prevent="startFileUpload"
>
{{ __('Upload file') }}
</a>
<item-button
:class="buttonCssClasses"
:show-label="showLabel"
:icon-classes="showLabel ? 'mr-2' : ''"
:label="__('Upload file')"
class="d-flex"
icon="upload"
@click="startFileUpload"
/>
<input
id="file-upload"
ref="fileUpload"

View File

@ -5,6 +5,7 @@ import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants';
import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue';
export default {
@ -16,9 +17,10 @@ export default {
PipelinesList,
JobsDetail,
ResizablePanel,
MergeRequestInfo,
},
computed: {
...mapState(['rightPane']),
...mapState(['rightPane', 'currentMergeRequestId']),
pipelinesActive() {
return (
this.rightPane === rightSidebarViews.pipelines ||
@ -54,10 +56,33 @@ export default {
</resizable-panel>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
<li
v-if="currentMergeRequestId"
>
<button
v-tooltip
:title="__('Merge Request')"
:aria-label="__('Merge Request')"
:class="{
active: rightPane === $options.rightSidebarViews.mergeRequestInfo
}"
data-container="body"
data-placement="left"
class="ide-sidebar-link is-right"
type="button"
@click="clickTab($event, $options.rightSidebarViews.mergeRequestInfo)"
>
<icon
:size="16"
name="text-description"
/>
</button>
</li>
<li>
<button
v-tooltip
:title="__('Pipelines')"
:aria-label="__('Pipelines')"
:class="{
active: pipelinesActive
}"

View File

@ -40,6 +40,11 @@ export default {
default: false,
},
},
data() {
return {
mouseOver: false,
};
},
computed: {
...mapGetters([
'getChangesInFolder',
@ -142,6 +147,9 @@ export default {
hasUrlAtCurrentRoute() {
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
toggleHover(over) {
this.mouseOver = over;
},
},
};
</script>
@ -153,6 +161,8 @@ export default {
class="file"
role="button"
@click="clickFile"
@mouseover="toggleHover(true)"
@mouseout="toggleHover(false)"
>
<div
class="file-name"
@ -206,6 +216,7 @@ export default {
:project-id="file.projectId"
:branch="file.branchId"
:path="file.path"
:mouse-over="mouseOver"
class="float-right prepend-left-8"
/>
</div>

View File

@ -31,6 +31,7 @@ export const diffModes = {
export const rightSidebarViews = {
pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info',
};
export const stageKeys = {

View File

@ -9,6 +9,7 @@ export default {
'diffEditor.insertedTextBackground': '#ddfbe6',
'diffEditor.removedTextBackground': '#f9d7dc',
'editor.selectionBackground': '#aad6f8',
'editorIndentGuide.activeBackground': '#cccccc',
},
},
};

View File

@ -40,8 +40,8 @@ export default {
getProjectData(namespace, project) {
return Api.project(`${namespace}/${project}`);
},
getProjectMergeRequestData(projectId, mergeRequestId) {
return Api.mergeRequest(projectId, mergeRequestId);
getProjectMergeRequestData(projectId, mergeRequestId, params = {}) {
return Api.mergeRequest(projectId, mergeRequestId, params);
},
getProjectMergeRequestChanges(projectId, mergeRequestId) {
return Api.mergeRequestChanges(projectId, mergeRequestId);

View File

@ -52,7 +52,7 @@ export const setResizingStatus = ({ commit }, resizing) => {
export const createTempEntry = (
{ state, commit, dispatch },
{ branchId, name, type, content = '', base64 = false },
{ name, type, content = '', base64 = false },
) =>
new Promise(resolve => {
const worker = new FilesDecoratorWorker();
@ -81,7 +81,7 @@ export const createTempEntry = (
commit(types.CREATE_TMP_ENTRY, {
data,
projectId: state.currentProjectId,
branchId,
branchId: state.currentBranchId,
});
if (type === 'blob') {
@ -100,7 +100,7 @@ export const createTempEntry = (
worker.postMessage({
data: [fullName],
projectId: state.currentProjectId,
branchId,
branchId: state.currentBranchId,
type,
tempFile: true,
base64,
@ -178,6 +178,13 @@ export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export const setErrorMessage = ({ commit }, errorMessage) =>
commit(types.SET_ERROR_MESSAGE, errorMessage);
export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
commit(types.OPEN_NEW_ENTRY_MODAL, { type, path });
// open the modal manually so we don't mess around with dropdown/rows
$('#ide-new-entry').modal('show');
};
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';

View File

@ -9,7 +9,7 @@ export const getMergeRequestData = (
new Promise((resolve, reject) => {
if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) {
service
.getProjectMergeRequestData(projectId, mergeRequestId)
.getProjectMergeRequestData(projectId, mergeRequestId, { render_html: true })
.then(({ data }) => {
commit(types.SET_MERGE_REQUEST, {
projectPath: projectId,

View File

@ -1,7 +1,6 @@
import $ from 'jquery';
import { sprintf, __ } from '~/locale';
import flash from '~/flash';
import { stripHtml } from '~/lib/utils/text_utility';
import * as rootTypes from '../../mutation_types';
import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
import router from '../../../ide_router';
@ -198,11 +197,18 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
if (err.response.status === 400) {
$('#ide-create-branch-modal').modal('show');
} else {
let errMsg = __('Error committing changes. Please try again.');
if (err.response.data && err.response.data.message) {
errMsg += ` (${stripHtml(err.response.data.message)})`;
}
flash(errMsg, 'alert', document, null, false, true);
dispatch(
'setErrorMessage',
{
text: __('An error accured whilst committing your changes.'),
action: () =>
dispatch('commitChanges').then(() =>
dispatch('setErrorMessage', null, { root: true }),
),
actionText: __('Please try again'),
},
{ root: true },
);
window.dispatchEvent(new Event('resize'));
}

View File

@ -1,6 +1,5 @@
import { __ } from '../../../../locale';
import Api from '../../../../api';
import flash from '../../../../flash';
import router from '../../../ide_router';
import { scopes } from './constants';
import * as types from './mutation_types';
@ -8,8 +7,20 @@ import * as rootTypes from '../../mutation_types';
export const requestMergeRequests = ({ commit }, type) =>
commit(types.REQUEST_MERGE_REQUESTS, type);
export const receiveMergeRequestsError = ({ commit }, type) => {
flash(__('Error loading merge requests.'));
export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }) => {
dispatch(
'setErrorMessage',
{
text: __('Error loading merge requests.'),
action: payload =>
dispatch('fetchMergeRequests', payload).then(() =>
dispatch('setErrorMessage', null, { root: true }),
),
actionText: __('Please try again'),
actionPayload: { type, search },
},
{ root: true },
);
commit(types.RECEIVE_MERGE_REQUESTS_ERROR, type);
};
export const receiveMergeRequestsSuccess = ({ commit }, { type, data }) =>
@ -20,9 +31,9 @@ export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, searc
dispatch('requestMergeRequests', type);
dispatch('resetMergeRequests', type);
Api.mergeRequests({ scope, state, search })
return Api.mergeRequests({ scope, state, search })
.then(({ data }) => dispatch('receiveMergeRequestsSuccess', { type, data }))
.catch(() => dispatch('receiveMergeRequestsError', type));
.catch(() => dispatch('receiveMergeRequestsError', { type, search }));
};
export const resetMergeRequests = ({ commit }, type) => commit(types.RESET_MERGE_REQUESTS, type);

View File

@ -1,7 +1,7 @@
import Visibility from 'visibilityjs';
import axios from 'axios';
import httpStatus from '../../../../lib/utils/http_status';
import { __ } from '../../../../locale';
import flash from '../../../../flash';
import Poll from '../../../../lib/utils/poll';
import service from '../../../services';
import { rightSidebarViews } from '../../../constants';
@ -18,10 +18,27 @@ export const stopPipelinePolling = () => {
export const restartPipelinePolling = () => {
if (eTagPoll) eTagPoll.restart();
};
export const forcePipelineRequest = () => {
if (eTagPoll) eTagPoll.makeRequest();
};
export const requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE);
export const receiveLatestPipelineError = ({ commit, dispatch }) => {
flash(__('There was an error loading latest pipeline'));
export const receiveLatestPipelineError = ({ commit, dispatch }, err) => {
if (err.status !== httpStatus.NOT_FOUND) {
dispatch(
'setErrorMessage',
{
text: __('An error occured whilst fetching the latest pipline.'),
action: () =>
dispatch('forcePipelineRequest').then(() =>
dispatch('setErrorMessage', null, { root: true }),
),
actionText: __('Please try again'),
actionPayload: null,
},
{ root: true },
);
}
commit(types.RECEIVE_LASTEST_PIPELINE_ERROR);
dispatch('stopPipelinePolling');
};
@ -46,7 +63,7 @@ export const fetchLatestPipeline = ({ dispatch, rootGetters }) => {
method: 'lastCommitPipelines',
data: { getters: rootGetters },
successCallback: ({ data }) => dispatch('receiveLatestPipelineSuccess', data),
errorCallback: () => dispatch('receiveLatestPipelineError'),
errorCallback: err => dispatch('receiveLatestPipelineError', err),
});
if (!Visibility.hidden()) {
@ -63,9 +80,21 @@ export const fetchLatestPipeline = ({ dispatch, rootGetters }) => {
};
export const requestJobs = ({ commit }, id) => commit(types.REQUEST_JOBS, id);
export const receiveJobsError = ({ commit }, id) => {
flash(__('There was an error loading jobs'));
commit(types.RECEIVE_JOBS_ERROR, id);
export const receiveJobsError = ({ commit, dispatch }, stage) => {
dispatch(
'setErrorMessage',
{
text: __('An error occured whilst loading the pipelines jobs.'),
action: payload =>
dispatch('fetchJobs', payload).then(() =>
dispatch('setErrorMessage', null, { root: true }),
),
actionText: __('Please try again'),
actionPayload: stage,
},
{ root: true },
);
commit(types.RECEIVE_JOBS_ERROR, stage.id);
};
export const receiveJobsSuccess = ({ commit }, { id, data }) =>
commit(types.RECEIVE_JOBS_SUCCESS, { id, data });
@ -73,10 +102,10 @@ export const receiveJobsSuccess = ({ commit }, { id, data }) =>
export const fetchJobs = ({ dispatch }, stage) => {
dispatch('requestJobs', stage.id);
axios
return axios
.get(stage.dropdownPath)
.then(({ data }) => dispatch('receiveJobsSuccess', { id: stage.id, data }))
.catch(() => dispatch('receiveJobsError', stage.id));
.catch(() => dispatch('receiveJobsError', stage));
};
export const toggleStageCollapsed = ({ commit }, stageId) =>
@ -90,8 +119,18 @@ export const setDetailJob = ({ commit, dispatch }, job) => {
};
export const requestJobTrace = ({ commit }) => commit(types.REQUEST_JOB_TRACE);
export const receiveJobTraceError = ({ commit }) => {
flash(__('Error fetching job trace'));
export const receiveJobTraceError = ({ commit, dispatch }) => {
dispatch(
'setErrorMessage',
{
text: __('An error occured whilst fetching the job trace.'),
action: () =>
dispatch('fetchJobTrace').then(() => dispatch('setErrorMessage', null, { root: true })),
actionText: __('Please try again'),
actionPayload: null,
},
{ root: true },
);
commit(types.RECEIVE_JOB_TRACE_ERROR);
};
export const receiveJobTraceSuccess = ({ commit }, data) =>

View File

@ -74,3 +74,5 @@ export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';

View File

@ -166,6 +166,11 @@ export default {
[types.SET_ERROR_MESSAGE](state, errorMessage) {
Object.assign(state, { errorMessage });
},
[types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) {
Object.assign(state, {
newEntryModal: { type, path },
});
},
...projectMutations,
...mergeRequestMutation,
...fileMutations,

Some files were not shown because too many files have changed in this diff Show More