Merge remote-tracking branch 'upstream/master' into qa-add-more-key-tests
* upstream/master: (536 commits) Fix flash errors in performance bar for cached responses Force content to align right Method to track recoverable exceptions in sentry Move PipelineFailed vue component fixed targetmode being included in project FE Docs: Fix header hierarchy in Vuex section of Vue.md Adds helpers to remove withespace and linebreaks `package-qa` was renamed to `package-and-qa` Fix container scanning in vendored GitLab CI configuration for Auto Devops Fixed web IDE not working for sub-groups Remove web ide beta flag from docs and update Make the message break into a new line instead of truncating it Backport Web IDE docs to gitlab-ce link to product handbook piece on permissions Update faraday_middlewar to 0.12.2 Double-check next value for internal ids. Resolve "skeleton placeholder on diff has white background" Replace the `project/commits/comments.feature` spinach test with an rspec analog Set ENV['IN_MEMORY_APPLICATION_SETTINGS'] to 'true in spec/db/production/settings_spec.rb Update docs on `.gitlab-ci.yml` and variables policy ...
This commit is contained in:
		
						commit
						8d4ad0c7db
					
				|  | @ -10,12 +10,6 @@ engines: | |||
|       - javascript | ||||
|     exclude_paths: | ||||
|       - "lib/api/v3/*" | ||||
|   eslint: | ||||
|     enabled: true | ||||
|     channel: "eslint-4" | ||||
|   rubocop: | ||||
|     enabled: true | ||||
|     channel: "gitlab-rubocop-0-52-1" | ||||
| ratings: | ||||
|   paths: | ||||
|   - Gemfile.lock | ||||
|  |  | |||
							
								
								
									
										173
									
								
								.gitlab-ci.yml
								
								
								
								
							
							
						
						
									
										173
									
								
								.gitlab-ci.yml
								
								
								
								
							|  | @ -1,4 +1,4 @@ | |||
| image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.6-golang-1.9-git-2.16-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6" | ||||
| image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git-2.17-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6" | ||||
| 
 | ||||
| .dedicated-runner: &dedicated-runner | ||||
|   retry: 1 | ||||
|  | @ -6,7 +6,7 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.6-golang-1.9-git | |||
|     - gitlab-org | ||||
| 
 | ||||
| .default-cache: &default-cache | ||||
|   key: "ruby-2.3.6-with-yarn" | ||||
|   key: "ruby-2.3.7-with-yarn" | ||||
|   paths: | ||||
|     - vendor/ruby | ||||
|     - .yarn-cache/ | ||||
|  | @ -78,6 +78,19 @@ stages: | |||
|     - mysql:latest | ||||
|     - redis:alpine | ||||
| 
 | ||||
| .rails5-variables: &rails5-variables | ||||
|   script: | ||||
|     - export RAILS5=${RAILS5} | ||||
|     - export BUNDLE_GEMFILE=${BUNDLE_GEMFILE} | ||||
| 
 | ||||
| .rails5: &rails5 | ||||
|   allow_failure: true | ||||
|   only: | ||||
|     - /rails5/ | ||||
|   variables: | ||||
|     BUNDLE_GEMFILE: "Gemfile.rails5" | ||||
|     RAILS5: "true" | ||||
| 
 | ||||
| # Skip all jobs except the ones that begin with 'docs/'. | ||||
| # Used for commits including ONLY documentation changes. | ||||
| # https://docs.gitlab.com/ce/development/writing_documentation.html#testing | ||||
|  | @ -118,6 +131,7 @@ stages: | |||
|   <<: *dedicated-runner | ||||
|   <<: *except-docs-and-qa | ||||
|   <<: *pull-cache | ||||
|   <<: *rails5-variables | ||||
|   stage: test | ||||
|   script: | ||||
|     - JOB_NAME=( $CI_JOB_NAME ) | ||||
|  | @ -148,14 +162,23 @@ stages: | |||
|   <<: *rspec-metadata | ||||
|   <<: *use-pg | ||||
| 
 | ||||
| .rspec-metadata-pg-rails5: &rspec-metadata-pg-rails5 | ||||
|   <<: *rspec-metadata-pg | ||||
|   <<: *rails5 | ||||
| 
 | ||||
| .rspec-metadata-mysql: &rspec-metadata-mysql | ||||
|   <<: *rspec-metadata | ||||
|   <<: *use-mysql | ||||
| 
 | ||||
| .rspec-metadata-mysql-rails5: &rspec-metadata-mysql-rails5 | ||||
|   <<: *rspec-metadata-mysql | ||||
|   <<: *rails5 | ||||
| 
 | ||||
| .spinach-metadata: &spinach-metadata | ||||
|   <<: *dedicated-runner | ||||
|   <<: *except-docs-and-qa | ||||
|   <<: *pull-cache | ||||
|   <<: *rails5-variables | ||||
|   stage: test | ||||
|   script: | ||||
|     - JOB_NAME=( $CI_JOB_NAME ) | ||||
|  | @ -179,10 +202,18 @@ stages: | |||
|   <<: *spinach-metadata | ||||
|   <<: *use-pg | ||||
| 
 | ||||
| .spinach-metadata-pg-rails5: &spinach-metadata-pg-rails5 | ||||
|   <<: *spinach-metadata-pg | ||||
|   <<: *rails5 | ||||
| 
 | ||||
| .spinach-metadata-mysql: &spinach-metadata-mysql | ||||
|   <<: *spinach-metadata | ||||
|   <<: *use-mysql | ||||
| 
 | ||||
| .spinach-metadata-mysql-rails5: &spinach-metadata-mysql-rails5 | ||||
|   <<: *spinach-metadata-mysql | ||||
|   <<: *rails5 | ||||
| 
 | ||||
| .only-canonical-masters: &only-canonical-masters | ||||
|   only: | ||||
|     - master@gitlab-org/gitlab-ce | ||||
|  | @ -266,12 +297,13 @@ package-and-qa: | |||
|   when: manual | ||||
|   variables: | ||||
|     GIT_STRATEGY: none | ||||
|   retry: 0 | ||||
|   before_script: | ||||
|     # We need to download the script rather than clone the repo since the | ||||
|     # package-and-qa job will not be able to run when the branch gets | ||||
|     # deleted (when merging the MR). | ||||
|     - apk add --update openssl | ||||
|     - wget https://gitlab.com/gitlab-org/gitlab-ce/raw/$CI_COMMIT_SHA/scripts/trigger-build-omnibus | ||||
|     - wget https://gitlab.com/$CI_PROJECT_PATH/raw/$CI_COMMIT_SHA/scripts/trigger-build-omnibus | ||||
|     - chmod 755 trigger-build-omnibus | ||||
|   script: | ||||
|     - ./trigger-build-omnibus | ||||
|  | @ -332,10 +364,11 @@ update-tests-metadata: | |||
|       - rspec_flaky/ | ||||
|     policy: push | ||||
|   script: | ||||
|     - retry gem install fog-aws mime-types | ||||
|     - retry gem install fog-aws mime-types activesupport | ||||
|     - scripts/merge-reports ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/rspec-pg_node_*.json | ||||
|     - scripts/merge-reports ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/spinach-pg_node_*.json | ||||
|     - scripts/merge-reports ${FLAKY_RSPEC_SUITE_REPORT_PATH} rspec_flaky/all_*_*.json | ||||
|     - FLAKY_RSPEC_GENERATE_REPORT=1 scripts/prune-old-flaky-specs ${FLAKY_RSPEC_SUITE_REPORT_PATH} | ||||
|     - '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH' | ||||
|     - '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $FLAKY_RSPEC_SUITE_REPORT_PATH' | ||||
|     - rm -f knapsack/${CI_PROJECT_NAME}/*_node_*.json | ||||
|  | @ -467,6 +500,70 @@ spinach-pg 1 2: *spinach-metadata-pg | |||
| spinach-mysql 0 2: *spinach-metadata-mysql | ||||
| spinach-mysql 1 2: *spinach-metadata-mysql | ||||
| 
 | ||||
| rspec-pg-rails5 0 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 1 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 2 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 3 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 4 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 5 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 6 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 7 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 8 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 9 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 10 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 11 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 12 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 13 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 14 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 15 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 16 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 17 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 18 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 19 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 20 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 21 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 22 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 23 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 24 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 25 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 26 28: *rspec-metadata-pg-rails5 | ||||
| rspec-pg-rails5 27 28: *rspec-metadata-pg-rails5 | ||||
| 
 | ||||
| rspec-mysql-rails5 0 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 1 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 2 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 3 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 4 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 5 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 6 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 7 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 8 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 9 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 10 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 11 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 12 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 13 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 14 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 15 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 16 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 17 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 18 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 19 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 20 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 21 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 22 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 23 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 24 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 25 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 26 28: *rspec-metadata-mysql-rails5 | ||||
| rspec-mysql-rails5 27 28: *rspec-metadata-mysql-rails5 | ||||
| 
 | ||||
| spinach-pg-rails5 0 2: *spinach-metadata-pg-rails5 | ||||
| spinach-pg-rails5 1 2: *spinach-metadata-pg-rails5 | ||||
| 
 | ||||
| spinach-mysql-rails5 0 2: *spinach-metadata-mysql-rails5 | ||||
| spinach-mysql-rails5 1 2: *spinach-metadata-mysql-rails5 | ||||
| 
 | ||||
| static-analysis: | ||||
|   <<: *dedicated-no-docs-no-db-pull-cache-job | ||||
|   dependencies: | ||||
|  | @ -475,7 +572,7 @@ static-analysis: | |||
|   script: | ||||
|     - scripts/static-analysis | ||||
|   cache: | ||||
|     key: "ruby-2.3.6-with-yarn-and-rubocop" | ||||
|     key: "ruby-2.3.7-with-yarn-and-rubocop" | ||||
|     paths: | ||||
|       - vendor/ruby | ||||
|       - .yarn-cache/ | ||||
|  | @ -617,36 +714,72 @@ karma: | |||
| 
 | ||||
| codequality: | ||||
|   <<: *dedicated-no-docs-no-db-pull-cache-job | ||||
|   image: docker:latest | ||||
|   image: docker:stable | ||||
|   allow_failure: true | ||||
|   # gitlab-org runners set `privileged: false` but we need to have it set to true | ||||
|   # since we're using Docker in Docker | ||||
|   tags: [] | ||||
|   before_script: [] | ||||
|   services: | ||||
|     - docker:dind | ||||
|     - docker:stable-dind | ||||
|   variables: | ||||
|     SETUP_DB: "false" | ||||
|     DOCKER_DRIVER: overlay2 | ||||
|     CODECLIMATE_FORMAT: json | ||||
|   cache: {} | ||||
|   dependencies: [] | ||||
|   script: | ||||
|     - apk update && apk add jq | ||||
|     - ./scripts/codequality analyze -f json > raw_codeclimate.json || true | ||||
|     # The following line keeps only the fields used in the MR widget, reducing the JSON artifact size | ||||
|     - jq -c 'map({check_name,description,fingerprint,location})' raw_codeclimate.json > codeclimate.json | ||||
|     # Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products | ||||
|     - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') | ||||
|     - docker run --env SOURCE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code | ||||
|   artifacts: | ||||
|     paths: [codeclimate.json] | ||||
|     expire_in: 1 week | ||||
| 
 | ||||
| sast: | ||||
|   <<: *except-docs | ||||
|   image: registry.gitlab.com/gitlab-org/gl-sast:latest | ||||
|   <<: *dedicated-no-docs-no-db-pull-cache-job | ||||
|   image: docker:stable | ||||
|   variables: | ||||
|     CONFIDENCE_LEVEL: 2 | ||||
|     SAST_CONFIDENCE_LEVEL: 2 | ||||
|     DOCKER_DRIVER: overlay2 | ||||
|   allow_failure: true | ||||
|   tags: [] | ||||
|   before_script: [] | ||||
|   cache: {} | ||||
|   dependencies: [] | ||||
|   services: | ||||
|     - docker:stable-dind | ||||
|   script: | ||||
|     - /app/bin/run . | ||||
|     - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') | ||||
|     - docker run | ||||
|         --env SAST_CONFIDENCE_LEVEL="${SAST_CONFIDENCE_LEVEL:-3}" | ||||
|         --volume "$PWD:/code" | ||||
|         --volume /var/run/docker.sock:/var/run/docker.sock | ||||
|         "registry.gitlab.com/gitlab-org/security-products/sast:$SP_VERSION" /app/bin/run /code | ||||
|   artifacts: | ||||
|     paths: [gl-sast-report.json] | ||||
| 
 | ||||
| dependency_scanning: | ||||
|   <<: *dedicated-no-docs-no-db-pull-cache-job | ||||
|   image: docker:stable | ||||
|   variables: | ||||
|     DOCKER_DRIVER: overlay2 | ||||
|   allow_failure: true | ||||
|   tags: [] | ||||
|   before_script: [] | ||||
|   cache: {} | ||||
|   dependencies: [] | ||||
|   services: | ||||
|     - docker:stable-dind | ||||
|   script: | ||||
|     - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') | ||||
|     - docker run | ||||
|         --env DEP_SCAN_DISABLE_REMOTE_CHECKS="${DEP_SCAN_DISABLE_REMOTE_CHECKS:-false}" | ||||
|         --volume "$PWD:/code" | ||||
|         --volume /var/run/docker.sock:/var/run/docker.sock | ||||
|         "registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$SP_VERSION" /code | ||||
|   artifacts: | ||||
|     paths: [gl-dependency-scanning-report.json] | ||||
| 
 | ||||
| qa:internal: | ||||
|   <<: *dedicated-no-docs-no-db-pull-cache-job | ||||
|   services: [] | ||||
|  | @ -664,7 +797,13 @@ qa:selectors: | |||
|     - bundle exec bin/qa Test::Sanity::Selectors | ||||
| 
 | ||||
| coverage: | ||||
|   <<: *dedicated-no-docs-no-db-pull-cache-job | ||||
|   # Don't include dedicated-no-docs-no-db-pull-cache-job here since we need to | ||||
|   # download artifacts from all the rspec jobs instead of from setup-test-env only | ||||
|   <<: *dedicated-runner | ||||
|   <<: *except-docs-and-qa | ||||
|   <<: *pull-cache | ||||
|   variables: | ||||
|     SETUP_DB: "false" | ||||
|   stage: post-test | ||||
|   script: | ||||
|     - bundle exec scripts/merge-simplecov | ||||
|  |  | |||
|  | @ -33,13 +33,16 @@ When removing columns, tables, indexes or other structures: | |||
| 
 | ||||
| ## General Checklist | ||||
| 
 | ||||
| - [ ] [Changelog entry](https://docs.gitlab.com/ce/development/changelog.html) added, if necessary | ||||
| - [ ] [Documentation created/updated](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/doc_styleguide.md) | ||||
| - [ ] [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) | ||||
| - [ ] API support added | ||||
| - [ ] Tests added for this feature/bug | ||||
| - Review | ||||
|   - [ ] Has been reviewed by Backend | ||||
|   - [ ] Has been reviewed by Database | ||||
| - [ ] Conform by the [merge request performance guides](http://docs.gitlab.com/ce/development/merge_request_performance_guidelines.html) | ||||
| - [ ] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#style-guides) | ||||
| - [ ] Conform by the [merge request performance guides](https://docs.gitlab.com/ee/development/merge_request_performance_guidelines.html) | ||||
| - [ ] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ee/blob/master/CONTRIBUTING.md#style-guides) | ||||
| - [ ] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits) | ||||
| - [ ] Internationalization required/considered | ||||
| - [ ] If paid feature, have we considered GitLab.com plan and how it works for groups and is there a design for promoting it to users who aren't on the correct plan | ||||
| - [ ] End-to-end tests pass (`package-and-qa` manual pipeline job) | ||||
|  |  | |||
|  | @ -1 +1 @@ | |||
| 2.3.6 | ||||
| 2.3.7 | ||||
|  |  | |||
|  | @ -59,6 +59,8 @@ linters: | |||
|   # Reports when you define the same property twice in a single rule set. | ||||
|   DuplicateProperty: | ||||
|     enabled: true | ||||
|     ignore_consecutive: | ||||
|       - cursor | ||||
| 
 | ||||
|   # Separate rule, function, and mixin declarations with empty lines. | ||||
|   EmptyLineBetweenBlocks: | ||||
|  |  | |||
							
								
								
									
										43
									
								
								CHANGELOG.md
								
								
								
								
							
							
						
						
									
										43
									
								
								CHANGELOG.md
								
								
								
								
							|  | @ -2,6 +2,32 @@ | |||
| documentation](doc/development/changelog.md) for instructions on adding your own | ||||
| entry. | ||||
| 
 | ||||
| ## 10.6.4 (2018-04-09) | ||||
| 
 | ||||
| ### Fixed (8 changes, 1 of them is from the community) | ||||
| 
 | ||||
| - Correct copy text for the promote milestone and label modals. !17726 | ||||
| - Avoid validation errors when running the Pages domain verification service. !17992 | ||||
| - Fix autolinking URLs containing ampersands. !18045 | ||||
| - Fix exceptions raised when migrating pipeline stages in the background. !18076 | ||||
| - Work around Prometheus Helm chart name changes to fix integration. !18206 (joshlambert) | ||||
| - Don't show Jump to Discussion button on Issues. | ||||
| - Fix listing commit branch/tags that contain special characters. | ||||
| - Fix 404 in group boards when moving issue between lists. | ||||
| 
 | ||||
| ### Performance (1 change) | ||||
| 
 | ||||
| - Free open file descriptors and libgit2 buffers in UpdatePagesService. | ||||
| 
 | ||||
| 
 | ||||
| ## 10.6.3 (2018-04-03) | ||||
| 
 | ||||
| ### Security (2 changes) | ||||
| 
 | ||||
| - Fix XSS on diff view stored on filenames. | ||||
| - Adds confidential notes channel for Slack/Mattermost. | ||||
| 
 | ||||
| 
 | ||||
| ## 10.6.2 (2018-03-29) | ||||
| 
 | ||||
| ### Fixed (2 changes, 1 of them is from the community) | ||||
|  | @ -191,7 +217,6 @@ entry. | |||
| - Enable privileged mode for GitLab Runner. !17528 | ||||
| - Expose GITLAB_FEATURES as CI/CD variable (fixes #40994). | ||||
| - Upgrade GitLab Workhorse to 4.0.0. | ||||
| - Allow CI/CD Jobs being grouped on version strings. | ||||
| - Add discussions API for Issues and Snippets. | ||||
| - Add one group board to Libre. | ||||
| - Add support for filtering by source and target branch to merge requests API. | ||||
|  | @ -218,6 +243,14 @@ entry. | |||
| - Use host URL to build JIRA remote link icon. | ||||
| 
 | ||||
| 
 | ||||
| ## 10.5.7 (2018-04-03) | ||||
| 
 | ||||
| ### Security (2 changes) | ||||
| 
 | ||||
| - Fix XSS on diff view stored on filenames. | ||||
| - Adds confidential notes channel for Slack/Mattermost. | ||||
| 
 | ||||
| 
 | ||||
| ## 10.5.6 (2018-03-16) | ||||
| 
 | ||||
| ### Security (2 changes) | ||||
|  | @ -485,6 +518,14 @@ entry. | |||
| - Adds empty state illustration for pending job. | ||||
| 
 | ||||
| 
 | ||||
| ## 10.4.7 (2018-04-03) | ||||
| 
 | ||||
| ### Security (2 changes) | ||||
| 
 | ||||
| - Fix XSS on diff view stored on filenames. | ||||
| - Adds confidential notes channel for Slack/Mattermost. | ||||
| 
 | ||||
| 
 | ||||
| ## 10.4.6 (2018-03-16) | ||||
| 
 | ||||
| ### Security (2 changes) | ||||
|  |  | |||
|  | @ -1 +1 @@ | |||
| 0.92.0 | ||||
| 0.95.0 | ||||
|  |  | |||
|  | @ -1 +1 @@ | |||
| 0.7.1 | ||||
| 0.8.0 | ||||
|  |  | |||
|  | @ -1 +1 @@ | |||
| 7.1.1 | ||||
| 7.1.2 | ||||
|  |  | |||
|  | @ -1 +1 @@ | |||
| 4.0.0 | ||||
| 4.1.0 | ||||
|  |  | |||
							
								
								
									
										16
									
								
								Gemfile
								
								
								
								
							
							
						
						
									
										16
									
								
								Gemfile
								
								
								
								
							|  | @ -82,16 +82,9 @@ gem 'net-ldap' | |||
| 
 | ||||
| # Git Wiki | ||||
| # Required manually in config/initializers/gollum.rb to control load order | ||||
| # Before updating this gem, check if | ||||
| # https://github.com/gollum/gollum-lib/pull/292 has been merged. | ||||
| # If it has, then remove the monkey patch for update_page, rename_page and raw_data_in_committer | ||||
| # in config/initializers/gollum.rb | ||||
| gem 'gollum-lib', '~> 4.2', require: false | ||||
| gem 'gitlab-gollum-lib', '~> 4.2' | ||||
| 
 | ||||
| # Before updating this gem, check if | ||||
| # https://github.com/gollum/rugged_adapter/pull/28 has been merged. | ||||
| # If it has, then remove the monkey patch for tree_entry in config/initializers/gollum.rb | ||||
| gem 'gollum-rugged_adapter', '~> 0.4.4', require: false | ||||
| gem 'gitlab-gollum-rugged_adapter', '~> 0.4.4', require: false | ||||
| 
 | ||||
| # Language detection | ||||
| gem 'github-linguist', '~> 5.3.3', require: 'linguist' | ||||
|  | @ -384,6 +377,7 @@ group :test do | |||
|   gem 'email_spec', '~> 1.6.0' | ||||
|   gem 'json-schema', '~> 2.8.0' | ||||
|   gem 'webmock', '~> 2.3.2' | ||||
|   gem 'rails-controller-testing' if rails5? # Rails5 only gem. | ||||
|   gem 'test_after_commit', '~> 1.1' unless rails5? # Remove this gem when migrated to rails 5.0. It's been integrated to rails 5.0. | ||||
|   gem 'sham_rack', '~> 1.3.6' | ||||
|   gem 'concurrent-ruby', '~> 1.0.5' | ||||
|  | @ -421,7 +415,7 @@ group :ed25519 do | |||
| end | ||||
| 
 | ||||
| # Gitaly GRPC client | ||||
| gem 'gitaly-proto', '~> 0.91.0', require: 'gitaly' | ||||
| gem 'gitaly-proto', '~> 0.94.0', require: 'gitaly' | ||||
| gem 'grpc', '~> 1.10.0' | ||||
| 
 | ||||
| # Locked until https://github.com/google/protobuf/issues/4210 is closed | ||||
|  | @ -440,3 +434,5 @@ gem 'grape_logging', '~> 1.7' | |||
| 
 | ||||
| # Asset synchronization | ||||
| gem 'asset_sync', '~> 2.2.0' | ||||
| 
 | ||||
| gem 'goldiloader', '~> 2.0' | ||||
|  |  | |||
							
								
								
									
										42
									
								
								Gemfile.lock
								
								
								
								
							
							
						
						
									
										42
									
								
								Gemfile.lock
								
								
								
								
							|  | @ -206,7 +206,7 @@ GEM | |||
|       railties (>= 3.0.0) | ||||
|     faraday (0.12.2) | ||||
|       multipart-post (>= 1.2, < 3) | ||||
|     faraday_middleware (0.11.0.1) | ||||
|     faraday_middleware (0.12.2) | ||||
|       faraday (>= 0.7.4, < 1.0) | ||||
|     faraday_middleware-multi_json (0.0.6) | ||||
|       faraday_middleware | ||||
|  | @ -290,7 +290,7 @@ GEM | |||
|       po_to_json (>= 1.0.0) | ||||
|       rails (>= 3.2.0) | ||||
|     gherkin-ruby (0.3.2) | ||||
|     gitaly-proto (0.91.0) | ||||
|     gitaly-proto (0.94.0) | ||||
|       google-protobuf (~> 3.1) | ||||
|       grpc (~> 1.0) | ||||
|     github-linguist (5.3.3) | ||||
|  | @ -298,11 +298,22 @@ GEM | |||
|       escape_utils (~> 1.1.0) | ||||
|       mime-types (>= 1.19) | ||||
|       rugged (>= 0.25.1) | ||||
|     github-markup (1.6.1) | ||||
|     github-markup (1.7.0) | ||||
|     gitlab-flowdock-git-hook (1.0.1) | ||||
|       flowdock (~> 0.7) | ||||
|       gitlab-grit (>= 2.4.1) | ||||
|       multi_json | ||||
|     gitlab-gollum-lib (4.2.7.1) | ||||
|       gemojione (~> 3.2) | ||||
|       github-markup (~> 1.6) | ||||
|       gollum-grit_adapter (~> 1.0) | ||||
|       nokogiri (>= 1.6.1, < 2.0) | ||||
|       rouge (~> 2.1) | ||||
|       sanitize (~> 2.1) | ||||
|       stringex (~> 2.6) | ||||
|     gitlab-gollum-rugged_adapter (0.4.4) | ||||
|       mime-types (>= 1.15) | ||||
|       rugged (~> 0.25) | ||||
|     gitlab-grit (2.8.2) | ||||
|       charlock_holmes (~> 0.6) | ||||
|       diff-lcs (~> 1.1) | ||||
|  | @ -320,19 +331,11 @@ GEM | |||
|       rubyntlm (~> 0.5) | ||||
|     globalid (0.4.1) | ||||
|       activesupport (>= 4.2.0) | ||||
|     goldiloader (2.0.1) | ||||
|       activerecord (>= 4.2, < 5.2) | ||||
|       activesupport (>= 4.2, < 5.2) | ||||
|     gollum-grit_adapter (1.0.1) | ||||
|       gitlab-grit (~> 2.7, >= 2.7.1) | ||||
|     gollum-lib (4.2.7) | ||||
|       gemojione (~> 3.2) | ||||
|       github-markup (~> 1.6) | ||||
|       gollum-grit_adapter (~> 1.0) | ||||
|       nokogiri (>= 1.6.1, < 2.0) | ||||
|       rouge (~> 2.1) | ||||
|       sanitize (~> 2.1) | ||||
|       stringex (~> 2.6) | ||||
|     gollum-rugged_adapter (0.4.4) | ||||
|       mime-types (>= 1.15) | ||||
|       rugged (~> 0.25) | ||||
|     gon (6.1.0) | ||||
|       actionpack (>= 3.0) | ||||
|       json | ||||
|  | @ -587,7 +590,7 @@ GEM | |||
|     orm_adapter (0.5.0) | ||||
|     os (0.9.6) | ||||
|     parallel (1.12.1) | ||||
|     parser (2.5.0.3) | ||||
|     parser (2.5.1.0) | ||||
|       ast (~> 2.4.0) | ||||
|     parslet (1.5.0) | ||||
|       blankslate (~> 2.0) | ||||
|  | @ -907,7 +910,7 @@ GEM | |||
|     state_machines-activerecord (0.5.1) | ||||
|       activerecord (>= 4.1, < 6.0) | ||||
|       state_machines-activemodel (>= 0.5.0) | ||||
|     stringex (2.7.1) | ||||
|     stringex (2.8.4) | ||||
|     sys-filesystem (1.1.6) | ||||
|       ffi | ||||
|     sysexits (1.2.0) | ||||
|  | @ -1061,14 +1064,15 @@ DEPENDENCIES | |||
|   gettext (~> 3.2.2) | ||||
|   gettext_i18n_rails (~> 1.8.0) | ||||
|   gettext_i18n_rails_js (~> 1.3) | ||||
|   gitaly-proto (~> 0.91.0) | ||||
|   gitaly-proto (~> 0.94.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.2) | ||||
|   gitlab-styles (~> 2.3) | ||||
|   gitlab_omniauth-ldap (~> 2.0.4) | ||||
|   gollum-lib (~> 4.2) | ||||
|   gollum-rugged_adapter (~> 0.4.4) | ||||
|   goldiloader (~> 2.0) | ||||
|   gon (~> 6.1.0) | ||||
|   google-api-client (~> 0.19.8) | ||||
|   google-protobuf (= 3.5.1) | ||||
|  |  | |||
|  | @ -97,7 +97,7 @@ GEM | |||
|       autoprefixer-rails (>= 5.2.1) | ||||
|       sass (>= 3.3.4) | ||||
|     bootstrap_form (2.7.0) | ||||
|     brakeman (3.6.2) | ||||
|     brakeman (4.2.1) | ||||
|     browser (2.5.3) | ||||
|     builder (3.2.3) | ||||
|     bullet (5.5.1) | ||||
|  | @ -291,7 +291,7 @@ GEM | |||
|       po_to_json (>= 1.0.0) | ||||
|       rails (>= 3.2.0) | ||||
|     gherkin-ruby (0.3.2) | ||||
|     gitaly-proto (0.91.0) | ||||
|     gitaly-proto (0.94.0) | ||||
|       google-protobuf (~> 3.1) | ||||
|       grpc (~> 1.0) | ||||
|     github-linguist (5.3.3) | ||||
|  | @ -321,6 +321,9 @@ GEM | |||
|       rubyntlm (~> 0.5) | ||||
|     globalid (0.4.1) | ||||
|       activesupport (>= 4.2.0) | ||||
|     goldiloader (2.0.1) | ||||
|       activerecord (>= 4.2, < 5.2) | ||||
|       activesupport (>= 4.2, < 5.2) | ||||
|     gollum-grit_adapter (1.0.1) | ||||
|       gitlab-grit (~> 2.7, >= 2.7.1) | ||||
|     gollum-lib (4.2.7) | ||||
|  | @ -400,7 +403,7 @@ GEM | |||
|     hipchat (1.5.4) | ||||
|       httparty | ||||
|       mimemagic | ||||
|     html-pipeline (2.6.0) | ||||
|     html-pipeline (2.7.1) | ||||
|       activesupport (>= 2) | ||||
|       nokogiri (>= 1.4) | ||||
|     html2text (0.2.1) | ||||
|  | @ -587,7 +590,7 @@ GEM | |||
|     orm_adapter (0.5.0) | ||||
|     os (0.9.6) | ||||
|     parallel (1.12.1) | ||||
|     parser (2.5.0.4) | ||||
|     parser (2.5.0.5) | ||||
|       ast (~> 2.4.0) | ||||
|     parslet (1.5.0) | ||||
|       blankslate (~> 2.0) | ||||
|  | @ -678,6 +681,10 @@ GEM | |||
|       bundler (>= 1.3.0) | ||||
|       railties (= 5.0.6) | ||||
|       sprockets-rails (>= 2.0.0) | ||||
|     rails-controller-testing (1.0.2) | ||||
|       actionpack (~> 5.x, >= 5.0.1) | ||||
|       actionview (~> 5.x, >= 5.0.1) | ||||
|       activesupport (~> 5.x) | ||||
|     rails-deprecated_sanitizer (1.0.3) | ||||
|       activesupport (>= 4.2.0.alpha) | ||||
|     rails-dom-testing (2.0.3) | ||||
|  | @ -874,7 +881,7 @@ GEM | |||
|       simplecov-html (~> 0.10.0) | ||||
|     simplecov-html (0.10.2) | ||||
|     slack-notifier (1.5.1) | ||||
|     spinach (0.10.1) | ||||
|     spinach (0.8.10) | ||||
|       colorize | ||||
|       gherkin-ruby (>= 0.3.2) | ||||
|       json | ||||
|  | @ -1013,7 +1020,7 @@ DEPENDENCIES | |||
|   binding_of_caller (~> 0.7.2) | ||||
|   bootstrap-sass (~> 3.3.0) | ||||
|   bootstrap_form (~> 2.7.0) | ||||
|   brakeman (~> 3.6.0) | ||||
|   brakeman (~> 4.2) | ||||
|   browser (~> 2.2) | ||||
|   bullet (~> 5.5.0) | ||||
|   bundler-audit (~> 0.5.0) | ||||
|  | @ -1062,12 +1069,13 @@ DEPENDENCIES | |||
|   gettext (~> 3.2.2) | ||||
|   gettext_i18n_rails (~> 1.8.0) | ||||
|   gettext_i18n_rails_js (~> 1.3) | ||||
|   gitaly-proto (~> 0.91.0) | ||||
|   gitaly-proto (~> 0.94.0) | ||||
|   github-linguist (~> 5.3.3) | ||||
|   gitlab-flowdock-git-hook (~> 1.0.1) | ||||
|   gitlab-markup (~> 1.6.2) | ||||
|   gitlab-styles (~> 2.3) | ||||
|   gitlab_omniauth-ldap (~> 2.0.4) | ||||
|   goldiloader (~> 2.0) | ||||
|   gollum-lib (~> 4.2) | ||||
|   gollum-rugged_adapter (~> 0.4.4) | ||||
|   gon (~> 6.1.0) | ||||
|  | @ -1084,7 +1092,7 @@ DEPENDENCIES | |||
|   hashie-forbidden_attributes | ||||
|   health_check (~> 2.6.0) | ||||
|   hipchat (~> 1.5.0) | ||||
|   html-pipeline (~> 2.6.0) | ||||
|   html-pipeline (~> 2.7.1) | ||||
|   html2text | ||||
|   httparty (~> 0.13.3) | ||||
|   influxdb (~> 0.2) | ||||
|  | @ -1145,6 +1153,7 @@ DEPENDENCIES | |||
|   rack-oauth2 (~> 1.2.1) | ||||
|   rack-proxy (~> 0.6.0) | ||||
|   rails (= 5.0.6) | ||||
|   rails-controller-testing | ||||
|   rails-deprecated_sanitizer (~> 1.0.3) | ||||
|   rails-i18n (~> 5.1) | ||||
|   rainbow (~> 2.2) | ||||
|  |  | |||
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1018 B | 
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 494 B | 
|  | @ -4,7 +4,8 @@ import $ from 'jquery'; | |||
| import _ from 'underscore'; | ||||
| import Cookies from 'js-cookie'; | ||||
| import { __ } from './locale'; | ||||
| import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; | ||||
| import { updateTooltipTitle } from './lib/utils/common_utils'; | ||||
| import { isInVueNoteablePage } from './lib/utils/dom_utils'; | ||||
| import flash from './flash'; | ||||
| import axios from './lib/utils/axios_utils'; | ||||
| 
 | ||||
|  | @ -243,7 +244,7 @@ class AwardsHandler { | |||
|   addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { | ||||
|     const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length; | ||||
| 
 | ||||
|     if (this.isInVueNoteablePage() && !isMainAwardsBlock) { | ||||
|     if (isInVueNoteablePage() && !isMainAwardsBlock) { | ||||
|       const id = votesBlock.attr('id').replace('note_', ''); | ||||
| 
 | ||||
|       this.hideMenuElement($('.emoji-menu')); | ||||
|  | @ -295,16 +296,8 @@ class AwardsHandler { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   isVueMRDiscussions() { | ||||
|     return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); | ||||
|   } | ||||
| 
 | ||||
|   isInVueNoteablePage() { | ||||
|     return isInIssuePage() || this.isVueMRDiscussions(); | ||||
|   } | ||||
| 
 | ||||
|   getVotesBlock() { | ||||
|     if (this.isInVueNoteablePage()) { | ||||
|     if (isInVueNoteablePage()) { | ||||
|       const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); | ||||
| 
 | ||||
|       if ($el.length) { | ||||
|  |  | |||
|  | @ -0,0 +1,121 @@ | |||
| <script> | ||||
| import Icon from '~/vue_shared/components/icon.vue'; | ||||
| import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; | ||||
| import Tooltip from '~/vue_shared/directives/tooltip'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'Badge', | ||||
|   components: { | ||||
|     Icon, | ||||
|     LoadingIcon, | ||||
|     Tooltip, | ||||
|   }, | ||||
|   directives: { | ||||
|     Tooltip, | ||||
|   }, | ||||
|   props: { | ||||
|     imageUrl: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     linkUrl: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       hasError: false, | ||||
|       isLoading: true, | ||||
|       numRetries: 0, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     imageUrlWithRetries() { | ||||
|       if (this.numRetries === 0) { | ||||
|         return this.imageUrl; | ||||
|       } | ||||
| 
 | ||||
|       return `${this.imageUrl}#retries=${this.numRetries}`; | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     imageUrl() { | ||||
|       this.hasError = false; | ||||
|       this.isLoading = true; | ||||
|       this.numRetries = 0; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     onError() { | ||||
|       this.isLoading = false; | ||||
|       this.hasError = true; | ||||
|     }, | ||||
|     onLoad() { | ||||
|       this.isLoading = false; | ||||
|     }, | ||||
|     reloadImage() { | ||||
|       this.hasError = false; | ||||
|       this.isLoading = true; | ||||
|       this.numRetries += 1; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div> | ||||
|     <a | ||||
|       v-show="!isLoading && !hasError" | ||||
|       :href="linkUrl" | ||||
|       target="_blank" | ||||
|       rel="noopener noreferrer" | ||||
|     > | ||||
|       <img | ||||
|         class="project-badge" | ||||
|         :src="imageUrlWithRetries" | ||||
|         @load="onLoad" | ||||
|         @error="onError" | ||||
|         aria-hidden="true" | ||||
|       /> | ||||
|     </a> | ||||
| 
 | ||||
|     <loading-icon | ||||
|       v-show="isLoading" | ||||
|       :inline="true" | ||||
|     /> | ||||
| 
 | ||||
|     <div | ||||
|       v-show="hasError" | ||||
|       class="btn-group" | ||||
|     > | ||||
|       <div class="btn btn-default btn-xs disabled"> | ||||
|         <icon | ||||
|           class="prepend-left-8 append-right-8" | ||||
|           name="doc_image" | ||||
|           :size="16" | ||||
|           aria-hidden="true" | ||||
|         /> | ||||
|       </div> | ||||
|       <div | ||||
|         class="btn btn-default btn-xs disabled" | ||||
|       > | ||||
|         <span class="prepend-left-8 append-right-8">{{ s__('Badges|No badge image') }}</span> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <button | ||||
|       v-show="hasError" | ||||
|       class="btn btn-transparent btn-xs text-primary" | ||||
|       type="button" | ||||
|       v-tooltip | ||||
|       :title="s__('Badges|Reload badge image')" | ||||
|       @click="reloadImage" | ||||
|     > | ||||
|       <icon | ||||
|         name="retry" | ||||
|         :size="16" | ||||
|       /> | ||||
|     </button> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,219 @@ | |||
| <script> | ||||
| import _ from 'underscore'; | ||||
| import { mapActions, mapState } from 'vuex'; | ||||
| import createFlash from '~/flash'; | ||||
| import { s__, sprintf } from '~/locale'; | ||||
| import LoadingButton from '~/vue_shared/components/loading_button.vue'; | ||||
| import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; | ||||
| import createEmptyBadge from '../empty_badge'; | ||||
| import Badge from './badge.vue'; | ||||
| 
 | ||||
| const badgePreviewDelayInMilliseconds = 1500; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'BadgeForm', | ||||
|   components: { | ||||
|     Badge, | ||||
|     LoadingButton, | ||||
|     LoadingIcon, | ||||
|   }, | ||||
|   props: { | ||||
|     isEditing: { | ||||
|       type: Boolean, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState([ | ||||
|       'badgeInAddForm', | ||||
|       'badgeInEditForm', | ||||
|       'docsUrl', | ||||
|       'isRendering', | ||||
|       'isSaving', | ||||
|       'renderedBadge', | ||||
|     ]), | ||||
|     badge() { | ||||
|       if (this.isEditing) { | ||||
|         return this.badgeInEditForm; | ||||
|       } | ||||
| 
 | ||||
|       return this.badgeInAddForm; | ||||
|     }, | ||||
|     canSubmit() { | ||||
|       return ( | ||||
|         this.badge !== null && | ||||
|         this.badge.imageUrl && | ||||
|         this.badge.imageUrl.trim() !== '' && | ||||
|         this.badge.linkUrl && | ||||
|         this.badge.linkUrl.trim() !== '' && | ||||
|         !this.isSaving | ||||
|       ); | ||||
|     }, | ||||
|     helpText() { | ||||
|       const placeholders = ['project_path', 'project_id', 'default_branch', 'commit_sha'] | ||||
|         .map(placeholder => `<code>%{${placeholder}}</code>`) | ||||
|         .join(', '); | ||||
|       return sprintf( | ||||
|         s__('Badges|The %{docsLinkStart}variables%{docsLinkEnd} GitLab supports: %{placeholders}'), | ||||
|         { | ||||
|           docsLinkEnd: '</a>', | ||||
|           docsLinkStart: `<a href="${_.escape(this.docsUrl)}">`, | ||||
|           placeholders, | ||||
|         }, | ||||
|         false, | ||||
|       ); | ||||
|     }, | ||||
|     renderedImageUrl() { | ||||
|       return this.renderedBadge ? this.renderedBadge.renderedImageUrl : ''; | ||||
|     }, | ||||
|     renderedLinkUrl() { | ||||
|       return this.renderedBadge ? this.renderedBadge.renderedLinkUrl : ''; | ||||
|     }, | ||||
|     imageUrl: { | ||||
|       get() { | ||||
|         return this.badge ? this.badge.imageUrl : ''; | ||||
|       }, | ||||
|       set(imageUrl) { | ||||
|         const badge = this.badge || createEmptyBadge(); | ||||
|         this.updateBadgeInForm({ | ||||
|           ...badge, | ||||
|           imageUrl, | ||||
|         }); | ||||
|       }, | ||||
|     }, | ||||
|     linkUrl: { | ||||
|       get() { | ||||
|         return this.badge ? this.badge.linkUrl : ''; | ||||
|       }, | ||||
|       set(linkUrl) { | ||||
|         const badge = this.badge || createEmptyBadge(); | ||||
|         this.updateBadgeInForm({ | ||||
|           ...badge, | ||||
|           linkUrl, | ||||
|         }); | ||||
|       }, | ||||
|     }, | ||||
|     submitButtonLabel() { | ||||
|       if (this.isEditing) { | ||||
|         return s__('Badges|Save changes'); | ||||
|       } | ||||
|       return s__('Badges|Add badge'); | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions(['addBadge', 'renderBadge', 'saveBadge', 'stopEditing', 'updateBadgeInForm']), | ||||
|     debouncedPreview: _.debounce(function preview() { | ||||
|       this.renderBadge(); | ||||
|     }, badgePreviewDelayInMilliseconds), | ||||
|     onCancel() { | ||||
|       this.stopEditing(); | ||||
|     }, | ||||
|     onSubmit() { | ||||
|       if (!this.canSubmit) { | ||||
|         return Promise.resolve(); | ||||
|       } | ||||
| 
 | ||||
|       if (this.isEditing) { | ||||
|         return this.saveBadge() | ||||
|           .then(() => { | ||||
|             createFlash(s__('Badges|The badge was saved.'), 'notice'); | ||||
|           }) | ||||
|           .catch(error => { | ||||
|             createFlash( | ||||
|               s__('Badges|Saving the badge failed, please check the entered URLs and try again.'), | ||||
|             ); | ||||
|             throw error; | ||||
|           }); | ||||
|       } | ||||
| 
 | ||||
|       return this.addBadge() | ||||
|         .then(() => { | ||||
|           createFlash(s__('Badges|A new badge was added.'), 'notice'); | ||||
|         }) | ||||
|         .catch(error => { | ||||
|           createFlash( | ||||
|             s__('Badges|Adding the badge failed, please check the entered URLs and try again.'), | ||||
|           ); | ||||
|           throw error; | ||||
|         }); | ||||
|     }, | ||||
|   }, | ||||
|   badgeImageUrlPlaceholder: | ||||
|     'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/<badge>.svg', | ||||
|   badgeLinkUrlPlaceholder: 'https://example.gitlab.com/%{project_path}', | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <form | ||||
|     class="prepend-top-default append-bottom-default" | ||||
|     @submit.prevent.stop="onSubmit" | ||||
|   > | ||||
|     <div class="form-group"> | ||||
|       <label for="badge-link-url">{{ s__('Badges|Link') }}</label> | ||||
|       <input | ||||
|         id="badge-link-url" | ||||
|         type="text" | ||||
|         class="form-control" | ||||
|         v-model="linkUrl" | ||||
|         :placeholder="$options.badgeLinkUrlPlaceholder" | ||||
|         @input="debouncedPreview" | ||||
|       /> | ||||
|       <span | ||||
|         class="help-block" | ||||
|         v-html="helpText" | ||||
|       ></span> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="form-group"> | ||||
|       <label for="badge-image-url">{{ s__('Badges|Badge image URL') }}</label> | ||||
|       <input | ||||
|         id="badge-image-url" | ||||
|         type="text" | ||||
|         class="form-control" | ||||
|         v-model="imageUrl" | ||||
|         :placeholder="$options.badgeImageUrlPlaceholder" | ||||
|         @input="debouncedPreview" | ||||
|       /> | ||||
|       <span | ||||
|         class="help-block" | ||||
|         v-html="helpText" | ||||
|       ></span> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="form-group"> | ||||
|       <label for="badge-preview">{{ s__('Badges|Badge image preview') }}</label> | ||||
|       <badge | ||||
|         id="badge-preview" | ||||
|         v-show="renderedBadge && !isRendering" | ||||
|         :image-url="renderedImageUrl" | ||||
|         :link-url="renderedLinkUrl" | ||||
|       /> | ||||
|       <p v-show="isRendering"> | ||||
|         <loading-icon | ||||
|           :inline="true" | ||||
|         /> | ||||
|       </p> | ||||
|       <p | ||||
|         v-show="!renderedBadge && !isRendering" | ||||
|         class="disabled-content" | ||||
|       >{{ s__('Badges|No image to preview') }}</p> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="row-content-block"> | ||||
|       <loading-button | ||||
|         type="submit" | ||||
|         container-class="btn btn-success" | ||||
|         :disabled="!canSubmit" | ||||
|         :loading="isSaving" | ||||
|         :label="submitButtonLabel" | ||||
|       /> | ||||
|       <button | ||||
|         class="btn btn-cancel" | ||||
|         type="button" | ||||
|         v-if="isEditing" | ||||
|         @click="onCancel" | ||||
|       >{{ __('Cancel') }}</button> | ||||
|     </div> | ||||
|   </form> | ||||
| </template> | ||||
|  | @ -0,0 +1,57 @@ | |||
| <script> | ||||
| import { mapState } from 'vuex'; | ||||
| import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; | ||||
| import BadgeListRow from './badge_list_row.vue'; | ||||
| import { GROUP_BADGE } from '../constants'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'BadgeList', | ||||
|   components: { | ||||
|     BadgeListRow, | ||||
|     LoadingIcon, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState(['badges', 'isLoading', 'kind']), | ||||
|     hasNoBadges() { | ||||
|       return !this.isLoading && (!this.badges || !this.badges.length); | ||||
|     }, | ||||
|     isGroupBadge() { | ||||
|       return this.kind === GROUP_BADGE; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="panel panel-default"> | ||||
|     <div class="panel-heading"> | ||||
|       {{ s__('Badges|Your badges') }} | ||||
|       <span | ||||
|         v-show="!isLoading" | ||||
|         class="badge" | ||||
|       >{{ badges.length }}</span> | ||||
|     </div> | ||||
|     <loading-icon | ||||
|       v-show="isLoading" | ||||
|       class="panel-body" | ||||
|       size="2" | ||||
|     /> | ||||
|     <div | ||||
|       v-if="hasNoBadges" | ||||
|       class="panel-body" | ||||
|     > | ||||
|       <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span> | ||||
|       <span v-else>{{ s__('Badges|This project has no badges') }}</span> | ||||
|     </div> | ||||
|     <div | ||||
|       v-else | ||||
|       class="panel-body" | ||||
|     > | ||||
|       <badge-list-row | ||||
|         v-for="badge in badges" | ||||
|         :key="badge.id" | ||||
|         :badge="badge" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,89 @@ | |||
| <script> | ||||
| import { mapActions, mapState } from 'vuex'; | ||||
| import { s__ } from '~/locale'; | ||||
| import Icon from '~/vue_shared/components/icon.vue'; | ||||
| import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; | ||||
| import { PROJECT_BADGE } from '../constants'; | ||||
| import Badge from './badge.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'BadgeListRow', | ||||
|   components: { | ||||
|     Badge, | ||||
|     Icon, | ||||
|     LoadingIcon, | ||||
|   }, | ||||
|   props: { | ||||
|     badge: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState(['kind']), | ||||
|     badgeKindText() { | ||||
|       if (this.badge.kind === PROJECT_BADGE) { | ||||
|         return s__('Badges|Project Badge'); | ||||
|       } | ||||
| 
 | ||||
|       return s__('Badges|Group Badge'); | ||||
|     }, | ||||
|     canEditBadge() { | ||||
|       return this.badge.kind === this.kind; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions(['editBadge', 'updateBadgeInModal']), | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="gl-responsive-table-row-layout gl-responsive-table-row"> | ||||
|     <badge | ||||
|       class="table-section section-30" | ||||
|       :image-url="badge.renderedImageUrl" | ||||
|       :link-url="badge.renderedLinkUrl" | ||||
|     /> | ||||
|     <span class="table-section section-50 str-truncated">{{ badge.linkUrl }}</span> | ||||
|     <div class="table-section section-10"> | ||||
|       <span class="badge">{{ badgeKindText }}</span> | ||||
|     </div> | ||||
|     <div class="table-section section-10 table-button-footer"> | ||||
|       <div | ||||
|         v-if="canEditBadge" | ||||
|         class="table-action-buttons"> | ||||
|         <button | ||||
|           class="btn btn-default append-right-8" | ||||
|           type="button" | ||||
|           :disabled="badge.isDeleting" | ||||
|           @click="editBadge(badge)" | ||||
|         > | ||||
|           <icon | ||||
|             name="pencil" | ||||
|             :size="16" | ||||
|             :aria-label="__('Edit')" | ||||
|           /> | ||||
|         </button> | ||||
|         <button | ||||
|           class="btn btn-danger" | ||||
|           type="button" | ||||
|           data-toggle="modal" | ||||
|           data-target="#delete-badge-modal" | ||||
|           :disabled="badge.isDeleting" | ||||
|           @click="updateBadgeInModal(badge)" | ||||
|         > | ||||
|           <icon | ||||
|             name="remove" | ||||
|             :size="16" | ||||
|             :aria-label="__('Delete')" | ||||
|           /> | ||||
|         </button> | ||||
|         <loading-icon | ||||
|           v-show="badge.isDeleting" | ||||
|           :inline="true" | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,70 @@ | |||
| <script> | ||||
| import { mapState, mapActions } from 'vuex'; | ||||
| import createFlash from '~/flash'; | ||||
| import { s__ } from '~/locale'; | ||||
| import GlModal from '~/vue_shared/components/gl_modal.vue'; | ||||
| import Badge from './badge.vue'; | ||||
| import BadgeForm from './badge_form.vue'; | ||||
| import BadgeList from './badge_list.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'BadgeSettings', | ||||
|   components: { | ||||
|     Badge, | ||||
|     BadgeForm, | ||||
|     BadgeList, | ||||
|     GlModal, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState(['badgeInModal', 'isEditing']), | ||||
|     deleteModalText() { | ||||
|       return s__( | ||||
|         'Badges|You are going to delete this badge. Deleted badges <strong>cannot</strong> be restored.', | ||||
|       ); | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions(['deleteBadge']), | ||||
|     onSubmitModal() { | ||||
|       this.deleteBadge(this.badgeInModal) | ||||
|         .then(() => { | ||||
|           createFlash(s__('Badges|The badge was deleted.'), 'notice'); | ||||
|         }) | ||||
|         .catch(error => { | ||||
|           createFlash(s__('Badges|Deleting the badge failed, please try again.')); | ||||
|           throw error; | ||||
|         }); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="badge-settings"> | ||||
|     <gl-modal | ||||
|       id="delete-badge-modal" | ||||
|       :header-title-text="s__('Badges|Delete badge?')" | ||||
|       footer-primary-button-variant="danger" | ||||
|       :footer-primary-button-text="s__('Badges|Delete badge')" | ||||
|       @submit="onSubmitModal"> | ||||
|       <div class="well"> | ||||
|         <badge | ||||
|           :image-url="badgeInModal ? badgeInModal.renderedImageUrl : ''" | ||||
|           :link-url="badgeInModal ? badgeInModal.renderedLinkUrl : ''" | ||||
|         /> | ||||
|       </div> | ||||
|       <p v-html="deleteModalText"></p> | ||||
|     </gl-modal> | ||||
| 
 | ||||
|     <badge-form | ||||
|       v-show="isEditing" | ||||
|       :is-editing="true" | ||||
|     /> | ||||
| 
 | ||||
|     <badge-form | ||||
|       v-show="!isEditing" | ||||
|       :is-editing="false" | ||||
|     /> | ||||
|     <badge-list v-show="!isEditing" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,2 @@ | |||
| export const GROUP_BADGE = 'group'; | ||||
| export const PROJECT_BADGE = 'project'; | ||||
|  | @ -0,0 +1,7 @@ | |||
| export default () => ({ | ||||
|   imageUrl: '', | ||||
|   isDeleting: false, | ||||
|   linkUrl: '', | ||||
|   renderedImageUrl: '', | ||||
|   renderedLinkUrl: '', | ||||
| }); | ||||
|  | @ -0,0 +1,167 @@ | |||
| import axios from '~/lib/utils/axios_utils'; | ||||
| import types from './mutation_types'; | ||||
| 
 | ||||
| export const transformBackendBadge = badge => ({ | ||||
|   id: badge.id, | ||||
|   imageUrl: badge.image_url, | ||||
|   kind: badge.kind, | ||||
|   linkUrl: badge.link_url, | ||||
|   renderedImageUrl: badge.rendered_image_url, | ||||
|   renderedLinkUrl: badge.rendered_link_url, | ||||
|   isDeleting: false, | ||||
| }); | ||||
| 
 | ||||
| export default { | ||||
|   requestNewBadge({ commit }) { | ||||
|     commit(types.REQUEST_NEW_BADGE); | ||||
|   }, | ||||
|   receiveNewBadge({ commit }, newBadge) { | ||||
|     commit(types.RECEIVE_NEW_BADGE, newBadge); | ||||
|   }, | ||||
|   receiveNewBadgeError({ commit }) { | ||||
|     commit(types.RECEIVE_NEW_BADGE_ERROR); | ||||
|   }, | ||||
|   addBadge({ dispatch, state }) { | ||||
|     const newBadge = state.badgeInAddForm; | ||||
|     const endpoint = state.apiEndpointUrl; | ||||
|     dispatch('requestNewBadge'); | ||||
|     return axios | ||||
|       .post(endpoint, { | ||||
|         image_url: newBadge.imageUrl, | ||||
|         link_url: newBadge.linkUrl, | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         dispatch('receiveNewBadgeError'); | ||||
|         throw error; | ||||
|       }) | ||||
|       .then(res => { | ||||
|         dispatch('receiveNewBadge', transformBackendBadge(res.data)); | ||||
|       }); | ||||
|   }, | ||||
|   requestDeleteBadge({ commit }, badgeId) { | ||||
|     commit(types.REQUEST_DELETE_BADGE, badgeId); | ||||
|   }, | ||||
|   receiveDeleteBadge({ commit }, badgeId) { | ||||
|     commit(types.RECEIVE_DELETE_BADGE, badgeId); | ||||
|   }, | ||||
|   receiveDeleteBadgeError({ commit }, badgeId) { | ||||
|     commit(types.RECEIVE_DELETE_BADGE_ERROR, badgeId); | ||||
|   }, | ||||
|   deleteBadge({ dispatch, state }, badge) { | ||||
|     const badgeId = badge.id; | ||||
|     dispatch('requestDeleteBadge', badgeId); | ||||
|     const endpoint = `${state.apiEndpointUrl}/${badgeId}`; | ||||
|     return axios | ||||
|       .delete(endpoint) | ||||
|       .catch(error => { | ||||
|         dispatch('receiveDeleteBadgeError', badgeId); | ||||
|         throw error; | ||||
|       }) | ||||
|       .then(() => { | ||||
|         dispatch('receiveDeleteBadge', badgeId); | ||||
|       }); | ||||
|   }, | ||||
| 
 | ||||
|   editBadge({ commit }, badge) { | ||||
|     commit(types.START_EDITING, badge); | ||||
|   }, | ||||
| 
 | ||||
|   requestLoadBadges({ commit }, data) { | ||||
|     commit(types.REQUEST_LOAD_BADGES, data); | ||||
|   }, | ||||
|   receiveLoadBadges({ commit }, badges) { | ||||
|     commit(types.RECEIVE_LOAD_BADGES, badges); | ||||
|   }, | ||||
|   receiveLoadBadgesError({ commit }) { | ||||
|     commit(types.RECEIVE_LOAD_BADGES_ERROR); | ||||
|   }, | ||||
| 
 | ||||
|   loadBadges({ dispatch, state }, data) { | ||||
|     dispatch('requestLoadBadges', data); | ||||
|     const endpoint = state.apiEndpointUrl; | ||||
|     return axios | ||||
|       .get(endpoint) | ||||
|       .catch(error => { | ||||
|         dispatch('receiveLoadBadgesError'); | ||||
|         throw error; | ||||
|       }) | ||||
|       .then(res => { | ||||
|         dispatch('receiveLoadBadges', res.data.map(transformBackendBadge)); | ||||
|       }); | ||||
|   }, | ||||
| 
 | ||||
|   requestRenderedBadge({ commit }) { | ||||
|     commit(types.REQUEST_RENDERED_BADGE); | ||||
|   }, | ||||
|   receiveRenderedBadge({ commit }, renderedBadge) { | ||||
|     commit(types.RECEIVE_RENDERED_BADGE, renderedBadge); | ||||
|   }, | ||||
|   receiveRenderedBadgeError({ commit }) { | ||||
|     commit(types.RECEIVE_RENDERED_BADGE_ERROR); | ||||
|   }, | ||||
| 
 | ||||
|   renderBadge({ dispatch, state }) { | ||||
|     const badge = state.isEditing ? state.badgeInEditForm : state.badgeInAddForm; | ||||
|     const { linkUrl, imageUrl } = badge; | ||||
|     if (!linkUrl || linkUrl.trim() === '' || !imageUrl || imageUrl.trim() === '') { | ||||
|       return Promise.resolve(badge); | ||||
|     } | ||||
| 
 | ||||
|     dispatch('requestRenderedBadge'); | ||||
| 
 | ||||
|     const parameters = [ | ||||
|       `link_url=${encodeURIComponent(linkUrl)}`, | ||||
|       `image_url=${encodeURIComponent(imageUrl)}`, | ||||
|     ].join('&'); | ||||
|     const renderEndpoint = `${state.apiEndpointUrl}/render?${parameters}`; | ||||
|     return axios | ||||
|       .get(renderEndpoint) | ||||
|       .catch(error => { | ||||
|         dispatch('receiveRenderedBadgeError'); | ||||
|         throw error; | ||||
|       }) | ||||
|       .then(res => { | ||||
|         dispatch('receiveRenderedBadge', transformBackendBadge(res.data)); | ||||
|       }); | ||||
|   }, | ||||
| 
 | ||||
|   requestUpdatedBadge({ commit }) { | ||||
|     commit(types.REQUEST_UPDATED_BADGE); | ||||
|   }, | ||||
|   receiveUpdatedBadge({ commit }, updatedBadge) { | ||||
|     commit(types.RECEIVE_UPDATED_BADGE, updatedBadge); | ||||
|   }, | ||||
|   receiveUpdatedBadgeError({ commit }) { | ||||
|     commit(types.RECEIVE_UPDATED_BADGE_ERROR); | ||||
|   }, | ||||
| 
 | ||||
|   saveBadge({ dispatch, state }) { | ||||
|     const badge = state.badgeInEditForm; | ||||
|     const endpoint = `${state.apiEndpointUrl}/${badge.id}`; | ||||
|     dispatch('requestUpdatedBadge'); | ||||
|     return axios | ||||
|       .put(endpoint, { | ||||
|         image_url: badge.imageUrl, | ||||
|         link_url: badge.linkUrl, | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         dispatch('receiveUpdatedBadgeError'); | ||||
|         throw error; | ||||
|       }) | ||||
|       .then(res => { | ||||
|         dispatch('receiveUpdatedBadge', transformBackendBadge(res.data)); | ||||
|       }); | ||||
|   }, | ||||
| 
 | ||||
|   stopEditing({ commit }) { | ||||
|     commit(types.STOP_EDITING); | ||||
|   }, | ||||
| 
 | ||||
|   updateBadgeInForm({ commit }, badge) { | ||||
|     commit(types.UPDATE_BADGE_IN_FORM, badge); | ||||
|   }, | ||||
| 
 | ||||
|   updateBadgeInModal({ commit }, badge) { | ||||
|     commit(types.UPDATE_BADGE_IN_MODAL, badge); | ||||
|   }, | ||||
| }; | ||||
|  | @ -0,0 +1,13 @@ | |||
| import Vue from 'vue'; | ||||
| import Vuex from 'vuex'; | ||||
| import createState from './state'; | ||||
| import actions from './actions'; | ||||
| import mutations from './mutations'; | ||||
| 
 | ||||
| Vue.use(Vuex); | ||||
| 
 | ||||
| export default new Vuex.Store({ | ||||
|   state: createState(), | ||||
|   actions, | ||||
|   mutations, | ||||
| }); | ||||
|  | @ -0,0 +1,21 @@ | |||
| export default { | ||||
|   RECEIVE_DELETE_BADGE: 'RECEIVE_DELETE_BADGE', | ||||
|   RECEIVE_DELETE_BADGE_ERROR: 'RECEIVE_DELETE_BADGE_ERROR', | ||||
|   RECEIVE_LOAD_BADGES: 'RECEIVE_LOAD_BADGES', | ||||
|   RECEIVE_LOAD_BADGES_ERROR: 'RECEIVE_LOAD_BADGES_ERROR', | ||||
|   RECEIVE_NEW_BADGE: 'RECEIVE_NEW_BADGE', | ||||
|   RECEIVE_NEW_BADGE_ERROR: 'RECEIVE_NEW_BADGE_ERROR', | ||||
|   RECEIVE_RENDERED_BADGE: 'RECEIVE_RENDERED_BADGE', | ||||
|   RECEIVE_RENDERED_BADGE_ERROR: 'RECEIVE_RENDERED_BADGE_ERROR', | ||||
|   RECEIVE_UPDATED_BADGE: 'RECEIVE_UPDATED_BADGE', | ||||
|   RECEIVE_UPDATED_BADGE_ERROR: 'RECEIVE_UPDATED_BADGE_ERROR', | ||||
|   REQUEST_DELETE_BADGE: 'REQUEST_DELETE_BADGE', | ||||
|   REQUEST_LOAD_BADGES: 'REQUEST_LOAD_BADGES', | ||||
|   REQUEST_NEW_BADGE: 'REQUEST_NEW_BADGE', | ||||
|   REQUEST_RENDERED_BADGE: 'REQUEST_RENDERED_BADGE', | ||||
|   REQUEST_UPDATED_BADGE: 'REQUEST_UPDATED_BADGE', | ||||
|   START_EDITING: 'START_EDITING', | ||||
|   STOP_EDITING: 'STOP_EDITING', | ||||
|   UPDATE_BADGE_IN_FORM: 'UPDATE_BADGE_IN_FORM', | ||||
|   UPDATE_BADGE_IN_MODAL: 'UPDATE_BADGE_IN_MODAL', | ||||
| }; | ||||
|  | @ -0,0 +1,158 @@ | |||
| import types from './mutation_types'; | ||||
| import { PROJECT_BADGE } from '../constants'; | ||||
| 
 | ||||
| const reorderBadges = badges => | ||||
|   badges.sort((a, b) => { | ||||
|     if (a.kind !== b.kind) { | ||||
|       return a.kind === PROJECT_BADGE ? 1 : -1; | ||||
|     } | ||||
| 
 | ||||
|     return a.id - b.id; | ||||
|   }); | ||||
| 
 | ||||
| export default { | ||||
|   [types.RECEIVE_NEW_BADGE](state, newBadge) { | ||||
|     Object.assign(state, { | ||||
|       badgeInAddForm: null, | ||||
|       badges: reorderBadges(state.badges.concat(newBadge)), | ||||
|       isSaving: false, | ||||
|       renderedBadge: null, | ||||
|     }); | ||||
|   }, | ||||
|   [types.RECEIVE_NEW_BADGE_ERROR](state) { | ||||
|     Object.assign(state, { | ||||
|       isSaving: false, | ||||
|     }); | ||||
|   }, | ||||
|   [types.REQUEST_NEW_BADGE](state) { | ||||
|     Object.assign(state, { | ||||
|       isSaving: true, | ||||
|     }); | ||||
|   }, | ||||
| 
 | ||||
|   [types.RECEIVE_UPDATED_BADGE](state, updatedBadge) { | ||||
|     const badges = state.badges.map(badge => { | ||||
|       if (badge.id === updatedBadge.id) { | ||||
|         return updatedBadge; | ||||
|       } | ||||
|       return badge; | ||||
|     }); | ||||
|     Object.assign(state, { | ||||
|       badgeInEditForm: null, | ||||
|       badges, | ||||
|       isEditing: false, | ||||
|       isSaving: false, | ||||
|       renderedBadge: null, | ||||
|     }); | ||||
|   }, | ||||
|   [types.RECEIVE_UPDATED_BADGE_ERROR](state) { | ||||
|     Object.assign(state, { | ||||
|       isSaving: false, | ||||
|     }); | ||||
|   }, | ||||
|   [types.REQUEST_UPDATED_BADGE](state) { | ||||
|     Object.assign(state, { | ||||
|       isSaving: true, | ||||
|     }); | ||||
|   }, | ||||
| 
 | ||||
|   [types.RECEIVE_LOAD_BADGES](state, badges) { | ||||
|     Object.assign(state, { | ||||
|       badges: reorderBadges(badges), | ||||
|       isLoading: false, | ||||
|     }); | ||||
|   }, | ||||
|   [types.RECEIVE_LOAD_BADGES_ERROR](state) { | ||||
|     Object.assign(state, { | ||||
|       isLoading: false, | ||||
|     }); | ||||
|   }, | ||||
|   [types.REQUEST_LOAD_BADGES](state, data) { | ||||
|     Object.assign(state, { | ||||
|       kind: data.kind, // project or group
 | ||||
|       apiEndpointUrl: data.apiEndpointUrl, | ||||
|       docsUrl: data.docsUrl, | ||||
|       isLoading: true, | ||||
|     }); | ||||
|   }, | ||||
| 
 | ||||
|   [types.RECEIVE_DELETE_BADGE](state, badgeId) { | ||||
|     const badges = state.badges.filter(badge => badge.id !== badgeId); | ||||
|     Object.assign(state, { | ||||
|       badges, | ||||
|     }); | ||||
|   }, | ||||
|   [types.RECEIVE_DELETE_BADGE_ERROR](state, badgeId) { | ||||
|     const badges = state.badges.map(badge => { | ||||
|       if (badge.id === badgeId) { | ||||
|         return { | ||||
|           ...badge, | ||||
|           isDeleting: false, | ||||
|         }; | ||||
|       } | ||||
| 
 | ||||
|       return badge; | ||||
|     }); | ||||
|     Object.assign(state, { | ||||
|       badges, | ||||
|     }); | ||||
|   }, | ||||
|   [types.REQUEST_DELETE_BADGE](state, badgeId) { | ||||
|     const badges = state.badges.map(badge => { | ||||
|       if (badge.id === badgeId) { | ||||
|         return { | ||||
|           ...badge, | ||||
|           isDeleting: true, | ||||
|         }; | ||||
|       } | ||||
| 
 | ||||
|       return badge; | ||||
|     }); | ||||
|     Object.assign(state, { | ||||
|       badges, | ||||
|     }); | ||||
|   }, | ||||
| 
 | ||||
|   [types.RECEIVE_RENDERED_BADGE](state, renderedBadge) { | ||||
|     Object.assign(state, { isRendering: false, renderedBadge }); | ||||
|   }, | ||||
|   [types.RECEIVE_RENDERED_BADGE_ERROR](state) { | ||||
|     Object.assign(state, { isRendering: false }); | ||||
|   }, | ||||
|   [types.REQUEST_RENDERED_BADGE](state) { | ||||
|     Object.assign(state, { isRendering: true }); | ||||
|   }, | ||||
| 
 | ||||
|   [types.START_EDITING](state, badge) { | ||||
|     Object.assign(state, { | ||||
|       badgeInEditForm: { ...badge }, | ||||
|       isEditing: true, | ||||
|       renderedBadge: { ...badge }, | ||||
|     }); | ||||
|   }, | ||||
|   [types.STOP_EDITING](state) { | ||||
|     Object.assign(state, { | ||||
|       badgeInEditForm: null, | ||||
|       isEditing: false, | ||||
|       renderedBadge: null, | ||||
|     }); | ||||
|   }, | ||||
| 
 | ||||
|   [types.UPDATE_BADGE_IN_FORM](state, badge) { | ||||
|     if (state.isEditing) { | ||||
|       Object.assign(state, { | ||||
|         badgeInEditForm: badge, | ||||
|       }); | ||||
|     } else { | ||||
|       Object.assign(state, { | ||||
|         badgeInAddForm: badge, | ||||
|       }); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   [types.UPDATE_BADGE_IN_MODAL](state, badge) { | ||||
|     Object.assign(state, { | ||||
|       badgeInModal: badge, | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
|  | @ -0,0 +1,13 @@ | |||
| export default () => ({ | ||||
|   apiEndpointUrl: null, | ||||
|   badgeInAddForm: null, | ||||
|   badgeInEditForm: null, | ||||
|   badgeInModal: null, | ||||
|   badges: [], | ||||
|   docsUrl: null, | ||||
|   renderedBadge: null, | ||||
|   isEditing: false, | ||||
|   isLoading: false, | ||||
|   isRendering: false, | ||||
|   isSaving: false, | ||||
| }); | ||||
|  | @ -94,7 +94,7 @@ export default class FileTemplateMediator { | |||
|       const hash = urlPieces[1]; | ||||
|       if (hash === 'preview') { | ||||
|         this.hideTemplateSelectorMenu(); | ||||
|       } else if (hash === 'editor') { | ||||
|       } else if (hash === 'editor' && !this.typeSelector.isHidden()) { | ||||
|         this.showTemplateSelectorMenu(); | ||||
|       } | ||||
|     }); | ||||
|  |  | |||
|  | @ -32,6 +32,10 @@ export default class FileTemplateSelector { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   isHidden() { | ||||
|     return this.$wrapper.hasClass('hidden'); | ||||
|   } | ||||
| 
 | ||||
|   getToggleText() { | ||||
|     return this.$dropdownToggleText.text(); | ||||
|   } | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ import Sortable from 'vendor/Sortable'; | |||
| import Vue from 'vue'; | ||||
| import AccessorUtilities from '../../lib/utils/accessor'; | ||||
| import boardList from './board_list.vue'; | ||||
| import boardBlankState from './board_blank_state'; | ||||
| import BoardBlankState from './board_blank_state.vue'; | ||||
| import './board_delete'; | ||||
| 
 | ||||
| const Store = gl.issueBoards.BoardsStore; | ||||
|  | @ -18,7 +18,7 @@ gl.issueBoards.Board = Vue.extend({ | |||
|   components: { | ||||
|     boardList, | ||||
|     'board-delete': gl.issueBoards.BoardDelete, | ||||
|     boardBlankState, | ||||
|     BoardBlankState, | ||||
|   }, | ||||
|   props: { | ||||
|     list: Object, | ||||
|  |  | |||
|  | @ -1,42 +1,11 @@ | |||
| <script> | ||||
| /* global ListLabel */ | ||||
| 
 | ||||
| import _ from 'underscore'; | ||||
| import Cookies from 'js-cookie'; | ||||
| 
 | ||||
| const Store = gl.issueBoards.BoardsStore; | ||||
| 
 | ||||
| export default { | ||||
|   template: ` | ||||
|     <div class="board-blank-state"> | ||||
|       <p> | ||||
|         Add the following default lists to your Issue Board with one click: | ||||
|       </p> | ||||
|       <ul class="board-blank-state-list"> | ||||
|         <li v-for="label in predefinedLabels"> | ||||
|           <span | ||||
|             class="label-color" | ||||
|             :style="{ backgroundColor: label.color }"> | ||||
|           </span> | ||||
|           {{ label.title }} | ||||
|         </li> | ||||
|       </ul> | ||||
|       <p> | ||||
|         Starting out with the default set of lists will get you right on the way to making the most of your board. | ||||
|       </p> | ||||
|       <button | ||||
|         class="btn btn-create btn-inverted btn-block" | ||||
|         type="button" | ||||
|         @click.stop="addDefaultLists"> | ||||
|         Add default lists | ||||
|       </button> | ||||
|       <button | ||||
|         class="btn btn-default btn-block" | ||||
|         type="button" | ||||
|         @click.stop="clearBlankState"> | ||||
|         Nevermind, I'll use my own | ||||
|       </button> | ||||
|     </div> | ||||
|   `, | ||||
|   data() { | ||||
|     return { | ||||
|       predefinedLabels: [ | ||||
|  | @ -89,3 +58,41 @@ export default { | |||
|     clearBlankState: Store.removeBlankState.bind(Store), | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="board-blank-state"> | ||||
|     <p> | ||||
|       Add the following default lists to your Issue Board with one click: | ||||
|     </p> | ||||
|     <ul class="board-blank-state-list"> | ||||
|       <li | ||||
|         v-for="(label, index) in predefinedLabels" | ||||
|         :key="index" | ||||
|       > | ||||
|         <span | ||||
|           class="label-color" | ||||
|           :style="{ backgroundColor: label.color }"> | ||||
|         </span> | ||||
|         {{ label.title }} | ||||
|       </li> | ||||
|     </ul> | ||||
|     <p> | ||||
|       Starting out with the default set of lists will get you | ||||
|       right on the way to making the most of your board. | ||||
|     </p> | ||||
|     <button | ||||
|       class="btn btn-create btn-inverted btn-block" | ||||
|       type="button" | ||||
|       @click.stop="addDefaultLists"> | ||||
|       Add default lists | ||||
|     </button> | ||||
|     <button | ||||
|       class="btn btn-default btn-block" | ||||
|       type="button" | ||||
|       @click.stop="clearBlankState"> | ||||
|       Nevermind, I'll use my own | ||||
|     </button> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -60,10 +60,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({ | |||
| 
 | ||||
|         this.issue = this.detail.issue; | ||||
|         this.list = this.detail.list; | ||||
| 
 | ||||
|         this.$nextTick(() => { | ||||
|           this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate; | ||||
|         }); | ||||
|       }, | ||||
|       deep: true | ||||
|     }, | ||||
|  | @ -91,7 +87,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ | |||
|     saveAssignees () { | ||||
|       this.loadingAssignees = true; | ||||
| 
 | ||||
|       gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint) | ||||
|       gl.issueBoards.BoardsStore.detail.issue.update() | ||||
|         .then(() => { | ||||
|           this.loadingAssignees = false; | ||||
|         }) | ||||
|  |  | |||
|  | @ -68,15 +68,6 @@ gl.issueBoards.IssueCardInner = Vue.extend({ | |||
| 
 | ||||
|       return this.issue.assignees.length > this.numberOverLimit; | ||||
|     }, | ||||
|     cardUrl() { | ||||
|       let baseUrl = this.issueLinkBase; | ||||
| 
 | ||||
|       if (this.groupId && this.issue.project) { | ||||
|         baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path); | ||||
|       } | ||||
| 
 | ||||
|       return `${baseUrl}/${this.issue.iid}`; | ||||
|     }, | ||||
|     issueId() { | ||||
|       if (this.issue.iid) { | ||||
|         return `#${this.issue.iid}`; | ||||
|  | @ -153,13 +144,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({ | |||
|           /> | ||||
|           <a | ||||
|             class="js-no-trigger" | ||||
|             :href="cardUrl" | ||||
|             :href="issue.path" | ||||
|             :title="issue.title">{{ issue.title }}</a> | ||||
|           <span | ||||
|             class="card-number" | ||||
|             v-if="issueId" | ||||
|           > | ||||
|             <template v-if="groupId && issue.project">{{issue.project.path}}</template>{{ issueId }} | ||||
|             {{ issue.referencePath }} | ||||
|           </span> | ||||
|         </h4> | ||||
|         <div class="card-assignee"> | ||||
|  |  | |||
|  | @ -1,9 +1,9 @@ | |||
| import Vue from 'vue'; | ||||
| 
 | ||||
| const ModalStore = gl.issueBoards.ModalStore; | ||||
| import ModalStore from '../../stores/modal_store'; | ||||
| import modalMixin from '../../mixins/modal_mixins'; | ||||
| 
 | ||||
| gl.issueBoards.ModalEmptyState = Vue.extend({ | ||||
|   mixins: [gl.issueBoards.ModalMixins], | ||||
|   mixins: [modalMixin], | ||||
|   data() { | ||||
|     return ModalStore.store; | ||||
|   }, | ||||
|  |  | |||
|  | @ -3,11 +3,11 @@ import Flash from '../../../flash'; | |||
| import { __ } from '../../../locale'; | ||||
| import './lists_dropdown'; | ||||
| import { pluralize } from '../../../lib/utils/text_utility'; | ||||
| 
 | ||||
| const ModalStore = gl.issueBoards.ModalStore; | ||||
| import ModalStore from '../../stores/modal_store'; | ||||
| import modalMixin from '../../mixins/modal_mixins'; | ||||
| 
 | ||||
| gl.issueBoards.ModalFooter = Vue.extend({ | ||||
|   mixins: [gl.issueBoards.ModalMixins], | ||||
|   mixins: [modalMixin], | ||||
|   data() { | ||||
|     return { | ||||
|       modal: ModalStore.store, | ||||
|  |  | |||
|  | @ -1,11 +1,11 @@ | |||
| import Vue from 'vue'; | ||||
| import modalFilters from './filters'; | ||||
| import './tabs'; | ||||
| 
 | ||||
| const ModalStore = gl.issueBoards.ModalStore; | ||||
| import ModalStore from '../../stores/modal_store'; | ||||
| import modalMixin from '../../mixins/modal_mixins'; | ||||
| 
 | ||||
| gl.issueBoards.ModalHeader = Vue.extend({ | ||||
|   mixins: [gl.issueBoards.ModalMixins], | ||||
|   mixins: [modalMixin], | ||||
|   props: { | ||||
|     projectId: { | ||||
|       type: Number, | ||||
|  |  | |||
|  | @ -7,8 +7,7 @@ import './header'; | |||
| import './list'; | ||||
| import './footer'; | ||||
| import './empty_state'; | ||||
| 
 | ||||
| const ModalStore = gl.issueBoards.ModalStore; | ||||
| import ModalStore from '../../stores/modal_store'; | ||||
| 
 | ||||
| gl.issueBoards.IssuesModal = Vue.extend({ | ||||
|   props: { | ||||
|  |  | |||
|  | @ -2,8 +2,7 @@ | |||
| 
 | ||||
| import Vue from 'vue'; | ||||
| import bp from '../../../breakpoints'; | ||||
| 
 | ||||
| const ModalStore = gl.issueBoards.ModalStore; | ||||
| import ModalStore from '../../stores/modal_store'; | ||||
| 
 | ||||
| gl.issueBoards.ModalList = Vue.extend({ | ||||
|   props: { | ||||
|  |  | |||
|  | @ -1,6 +1,5 @@ | |||
| import Vue from 'vue'; | ||||
| 
 | ||||
| const ModalStore = gl.issueBoards.ModalStore; | ||||
| import ModalStore from '../../stores/modal_store'; | ||||
| 
 | ||||
| gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ | ||||
|   data() { | ||||
|  |  | |||
|  | @ -1,9 +1,9 @@ | |||
| import Vue from 'vue'; | ||||
| 
 | ||||
| const ModalStore = gl.issueBoards.ModalStore; | ||||
| import ModalStore from '../../stores/modal_store'; | ||||
| import modalMixin from '../../mixins/modal_mixins'; | ||||
| 
 | ||||
| gl.issueBoards.ModalTabs = Vue.extend({ | ||||
|   mixins: [gl.issueBoards.ModalMixins], | ||||
|   mixins: [modalMixin], | ||||
|   data() { | ||||
|     return ModalStore.store; | ||||
|   }, | ||||
|  |  | |||
|  | @ -17,14 +17,10 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ | |||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|     issueUpdate: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     updateUrl() { | ||||
|       return this.issueUpdate.replace(':project_path', this.issue.project.path); | ||||
|       return this.issue.path; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { | |||
|   constructor(store, updateUrl = false, cantEdit = []) { | ||||
|     super({ | ||||
|       page: 'boards', | ||||
|       isGroupDecendent: true, | ||||
|       stateFiltersSelector: '.issues-state-filters', | ||||
|     }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -17,9 +17,9 @@ import './models/milestone'; | |||
| import './models/project'; | ||||
| import './models/assignee'; | ||||
| import './stores/boards_store'; | ||||
| import './stores/modal_store'; | ||||
| import ModalStore from './stores/modal_store'; | ||||
| import BoardService from './services/board_service'; | ||||
| import './mixins/modal_mixins'; | ||||
| import modalMixin from './mixins/modal_mixins'; | ||||
| import './mixins/sortable_default_options'; | ||||
| import './filters/due_date_filters'; | ||||
| import './components/board'; | ||||
|  | @ -31,7 +31,6 @@ import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/fi | |||
| export default () => { | ||||
|   const $boardApp = document.getElementById('board-app'); | ||||
|   const Store = gl.issueBoards.BoardsStore; | ||||
|   const ModalStore = gl.issueBoards.ModalStore; | ||||
| 
 | ||||
|   window.gl = window.gl || {}; | ||||
| 
 | ||||
|  | @ -176,7 +175,7 @@ export default () => { | |||
| 
 | ||||
|   gl.IssueBoardsModalAddBtn = new Vue({ | ||||
|     el: document.getElementById('js-add-issues-btn'), | ||||
|     mixins: [gl.issueBoards.ModalMixins], | ||||
|     mixins: [modalMixin], | ||||
|     data() { | ||||
|       return { | ||||
|         modal: ModalStore.store, | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| const ModalStore = gl.issueBoards.ModalStore; | ||||
| import ModalStore from '../stores/modal_store'; | ||||
| 
 | ||||
| gl.issueBoards.ModalMixins = { | ||||
| export default { | ||||
|   methods: { | ||||
|     toggleModal(toggle) { | ||||
|       ModalStore.store.showAddIssuesModal = toggle; | ||||
|  |  | |||
|  | @ -23,6 +23,8 @@ class ListIssue { | |||
|     }; | ||||
|     this.isLoading = {}; | ||||
|     this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; | ||||
|     this.referencePath = obj.reference_path; | ||||
|     this.path = obj.real_path; | ||||
|     this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; | ||||
|     this.milestone_id = obj.milestone_id; | ||||
|     this.project_id = obj.project_id; | ||||
|  | @ -98,7 +100,7 @@ class ListIssue { | |||
|     this.isLoading[key] = value; | ||||
|   } | ||||
| 
 | ||||
|   update (url) { | ||||
|   update () { | ||||
|     const data = { | ||||
|       issue: { | ||||
|         milestone_id: this.milestone ? this.milestone.id : null, | ||||
|  | @ -113,7 +115,7 @@ class ListIssue { | |||
|     } | ||||
| 
 | ||||
|     const projectPath = this.project ? this.project.path : ''; | ||||
|     return Vue.http.patch(url.replace(':project_path', projectPath), data); | ||||
|     return Vue.http.patch(`${this.path}.json`, data); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ export default class BoardService { | |||
|   } | ||||
| 
 | ||||
|   static generateIssuePath(boardId, id) { | ||||
|     return `${gon.relative_url_root}/-/boards/${boardId ? `/${boardId}` : ''}/issues${id ? `/${id}` : ''}`; | ||||
|     return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${id ? `/${id}` : ''}`; | ||||
|   } | ||||
| 
 | ||||
|   all() { | ||||
|  |  | |||
|  | @ -1,6 +1,3 @@ | |||
| window.gl = window.gl || {}; | ||||
| window.gl.issueBoards = window.gl.issueBoards || {}; | ||||
| 
 | ||||
| class ModalStore { | ||||
|   constructor() { | ||||
|     this.store = { | ||||
|  | @ -95,4 +92,4 @@ class ModalStore { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| gl.issueBoards.ModalStore = new ModalStore(); | ||||
| export default new ModalStore(); | ||||
|  |  | |||
|  | @ -55,14 +55,13 @@ | |||
|     }, | ||||
|     methods: { | ||||
|       successCallback(resp) { | ||||
|         return resp.json().then((response) => { | ||||
|         // depending of the endpoint the response can either bring a `pipelines` key or not. | ||||
|           const pipelines = response.pipelines || response; | ||||
|         const pipelines = resp.data.pipelines || resp.data; | ||||
|         this.setCommonData(pipelines); | ||||
| 
 | ||||
|         const updatePipelinesEvent = new CustomEvent('update-pipelines-count', { | ||||
|           detail: { | ||||
|               pipelines: response, | ||||
|             pipelines: resp.data, | ||||
|           }, | ||||
|         }); | ||||
| 
 | ||||
|  | @ -70,7 +69,6 @@ | |||
|         if (this.$el.parentElement) { | ||||
|           this.$el.parentElement.dispatchEvent(updatePipelinesEvent); | ||||
|         } | ||||
|         }); | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
|  |  | |||
|  | @ -1,19 +1,19 @@ | |||
| import $ from 'jquery'; | ||||
| import _ from 'underscore'; | ||||
| import { | ||||
|   getSelector, | ||||
|   togglePopover, | ||||
|   inserted, | ||||
|   mouseenter, | ||||
|   mouseleave, | ||||
| } from './feature_highlight_helper'; | ||||
| import { | ||||
|   togglePopover, | ||||
|   mouseenter, | ||||
|   debouncedMouseleave, | ||||
| } from '../shared/popover'; | ||||
| 
 | ||||
| export function setupFeatureHighlightPopover(id, debounceTimeout = 300) { | ||||
|   const $selector = $(getSelector(id)); | ||||
|   const $parent = $selector.parent(); | ||||
|   const $popoverContent = $parent.siblings('.feature-highlight-popover-content'); | ||||
|   const hideOnScroll = togglePopover.bind($selector, false); | ||||
|   const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout); | ||||
| 
 | ||||
|   $selector | ||||
|     // Setup popover
 | ||||
|  | @ -29,13 +29,10 @@ export function setupFeatureHighlightPopover(id, debounceTimeout = 300) { | |||
|       `,
 | ||||
|     }) | ||||
|     .on('mouseenter', mouseenter) | ||||
|     .on('mouseleave', debouncedMouseleave) | ||||
|     .on('mouseleave', debouncedMouseleave(debounceTimeout)) | ||||
|     .on('inserted.bs.popover', inserted) | ||||
|     .on('show.bs.popover', () => { | ||||
|       window.addEventListener('scroll', hideOnScroll); | ||||
|     }) | ||||
|     .on('hide.bs.popover', () => { | ||||
|       window.removeEventListener('scroll', hideOnScroll); | ||||
|       window.addEventListener('scroll', hideOnScroll, { once: true }); | ||||
|     }) | ||||
|     // Display feature highlight
 | ||||
|     .removeAttr('disabled'); | ||||
|  |  | |||
|  | @ -3,20 +3,10 @@ import axios from '../lib/utils/axios_utils'; | |||
| import { __ } from '../locale'; | ||||
| import Flash from '../flash'; | ||||
| import LazyLoader from '../lazy_loader'; | ||||
| import { togglePopover } from '../shared/popover'; | ||||
| 
 | ||||
| export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`; | ||||
| 
 | ||||
| export function togglePopover(show) { | ||||
|   const isAlreadyShown = this.hasClass('js-popover-show'); | ||||
|   if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) { | ||||
|     return false; | ||||
|   } | ||||
|   this.popover(show ? 'show' : 'hide'); | ||||
|   this.toggleClass('disable-animation js-popover-show', show); | ||||
| 
 | ||||
|   return true; | ||||
| } | ||||
| 
 | ||||
| export function dismiss(highlightId) { | ||||
|   axios.post(this.attr('data-dismiss-endpoint'), { | ||||
|     feature_name: highlightId, | ||||
|  | @ -27,23 +17,6 @@ export function dismiss(highlightId) { | |||
|   this.hide(); | ||||
| } | ||||
| 
 | ||||
| export function mouseleave() { | ||||
|   if (!$('.popover:hover').length > 0) { | ||||
|     const $featureHighlight = $(this); | ||||
|     togglePopover.call($featureHighlight, false); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function mouseenter() { | ||||
|   const $featureHighlight = $(this); | ||||
| 
 | ||||
|   const showedPopover = togglePopover.call($featureHighlight, true); | ||||
|   if (showedPopover) { | ||||
|     $('.popover') | ||||
|       .on('mouseleave', mouseleave.bind($featureHighlight)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function inserted() { | ||||
|   const popoverId = this.getAttribute('aria-describedby'); | ||||
|   const highlightId = this.dataset.highlight; | ||||
|  |  | |||
|  | @ -26,8 +26,8 @@ export default class FilteredSearchDropdownManager { | |||
|     this.filteredSearchInput = this.container.querySelector('.filtered-search'); | ||||
|     this.page = page; | ||||
|     this.groupsOnly = isGroup; | ||||
|     this.groupAncestor = isGroupAncestor; | ||||
|     this.isGroupDecendent = isGroupDecendent; | ||||
|     this.includeAncestorGroups = isGroupAncestor; | ||||
|     this.includeDescendantGroups = isGroupDecendent; | ||||
| 
 | ||||
|     this.setupMapping(); | ||||
| 
 | ||||
|  | @ -108,7 +108,19 @@ export default class FilteredSearchDropdownManager { | |||
|   } | ||||
| 
 | ||||
|   getLabelsEndpoint() { | ||||
|     const endpoint = `${this.baseEndpoint}/labels.json`; | ||||
|     let endpoint = `${this.baseEndpoint}/labels.json?`; | ||||
| 
 | ||||
|     if (this.groupsOnly) { | ||||
|       endpoint = `${endpoint}only_group_labels=true&`; | ||||
|     } | ||||
| 
 | ||||
|     if (this.includeAncestorGroups) { | ||||
|       endpoint = `${endpoint}include_ancestor_groups=true&`; | ||||
|     } | ||||
| 
 | ||||
|     if (this.includeDescendantGroups) { | ||||
|       endpoint = `${endpoint}include_descendant_groups=true`; | ||||
|     } | ||||
| 
 | ||||
|     return endpoint; | ||||
|   } | ||||
|  |  | |||
|  | @ -21,7 +21,7 @@ export default class FilteredSearchManager { | |||
|   constructor({ | ||||
|     page, | ||||
|     isGroup = false, | ||||
|     isGroupAncestor = false, | ||||
|     isGroupAncestor = true, | ||||
|     isGroupDecendent = false, | ||||
|     filteredSearchTokenKeys = FilteredSearchTokenKeys, | ||||
|     stateFiltersSelector = '.issues-state-filters', | ||||
|  | @ -86,6 +86,7 @@ export default class FilteredSearchManager { | |||
|         page: this.page, | ||||
|         isGroup: this.isGroup, | ||||
|         isGroupAncestor: this.isGroupAncestor, | ||||
|         isGroupDecendent: this.isGroupDecendent, | ||||
|         filteredSearchTokenKeys: this.filteredSearchTokenKeys, | ||||
|       }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,11 +1,10 @@ | |||
| <script> | ||||
|   import { mapActions } from 'vuex'; | ||||
|   import icon from '~/vue_shared/components/icon.vue'; | ||||
|   import router from '../../ide_router'; | ||||
| import { mapActions } from 'vuex'; | ||||
| import Icon from '~/vue_shared/components/icon.vue'; | ||||
| 
 | ||||
|   export default { | ||||
| export default { | ||||
|   components: { | ||||
|       icon, | ||||
|     Icon, | ||||
|   }, | ||||
|   props: { | ||||
|     file: { | ||||
|  | @ -22,17 +21,16 @@ | |||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|       ...mapActions([ | ||||
|         'discardFileChanges', | ||||
|         'updateViewer', | ||||
|       ]), | ||||
|     ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']), | ||||
|     openFileInEditor(file) { | ||||
|       return this.openPendingTab(file).then(changeViewer => { | ||||
|         if (changeViewer) { | ||||
|           this.updateViewer('diff'); | ||||
| 
 | ||||
|         router.push(`/project${file.url}`); | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|   }, | ||||
|   }; | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -3,7 +3,6 @@ import { mapState, mapGetters } from 'vuex'; | |||
| import ideSidebar from './ide_side_bar.vue'; | ||||
| import ideContextbar from './ide_context_bar.vue'; | ||||
| import repoTabs from './repo_tabs.vue'; | ||||
| import repoFileButtons from './repo_file_buttons.vue'; | ||||
| import ideStatusBar from './ide_status_bar.vue'; | ||||
| import repoEditor from './repo_editor.vue'; | ||||
| 
 | ||||
|  | @ -12,7 +11,6 @@ export default { | |||
|     ideSidebar, | ||||
|     ideContextbar, | ||||
|     repoTabs, | ||||
|     repoFileButtons, | ||||
|     ideStatusBar, | ||||
|     repoEditor, | ||||
|   }, | ||||
|  | @ -60,6 +58,7 @@ export default { | |||
|         v-if="activeFile" | ||||
|       > | ||||
|         <repo-tabs | ||||
|           :active-file="activeFile" | ||||
|           :files="openFiles" | ||||
|           :viewer="viewer" | ||||
|           :has-changes="hasChanges" | ||||
|  | @ -69,9 +68,6 @@ export default { | |||
|           class="multi-file-edit-pane-content" | ||||
|           :file="activeFile" | ||||
|         /> | ||||
|         <repo-file-buttons | ||||
|           :file="activeFile" | ||||
|         /> | ||||
|         <ide-status-bar | ||||
|           :file="activeFile" | ||||
|         /> | ||||
|  |  | |||
|  | @ -0,0 +1,84 @@ | |||
| <script> | ||||
| import { __ } from '~/locale'; | ||||
| import tooltip from '~/vue_shared/directives/tooltip'; | ||||
| import Icon from '~/vue_shared/components/icon.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     Icon, | ||||
|   }, | ||||
|   directives: { | ||||
|     tooltip, | ||||
|   }, | ||||
|   props: { | ||||
|     file: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     showButtons() { | ||||
|       return ( | ||||
|         this.file.rawPath || this.file.blamePath || this.file.commitsPath || this.file.permalink | ||||
|       ); | ||||
|     }, | ||||
|     rawDownloadButtonLabel() { | ||||
|       return this.file.binary ? __('Download') : __('Raw'); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div | ||||
|     v-if="showButtons" | ||||
|     class="pull-right ide-btn-group" | ||||
|   > | ||||
|     <a | ||||
|       v-tooltip | ||||
|       v-if="!file.binary" | ||||
|       :href="file.blamePath" | ||||
|       :title="__('Blame')" | ||||
|       class="btn btn-xs btn-transparent blame" | ||||
|     > | ||||
|       <icon | ||||
|         name="blame" | ||||
|         :size="16" | ||||
|       /> | ||||
|     </a> | ||||
|     <a | ||||
|       v-tooltip | ||||
|       :href="file.commitsPath" | ||||
|       :title="__('History')" | ||||
|       class="btn btn-xs btn-transparent history" | ||||
|     > | ||||
|       <icon | ||||
|         name="history" | ||||
|         :size="16" | ||||
|       /> | ||||
|     </a> | ||||
|     <a | ||||
|       v-tooltip | ||||
|       :href="file.permalink" | ||||
|       :title="__('Permalink')" | ||||
|       class="btn btn-xs btn-transparent permalink" | ||||
|     > | ||||
|       <icon | ||||
|         name="link" | ||||
|         :size="16" | ||||
|       /> | ||||
|     </a> | ||||
|     <a | ||||
|       v-tooltip | ||||
|       :href="file.rawPath" | ||||
|       target="_blank" | ||||
|       class="btn btn-xs btn-transparent prepend-left-10 raw" | ||||
|       rel="noopener noreferrer" | ||||
|       :title="rawDownloadButtonLabel"> | ||||
|       <icon | ||||
|         name="download" | ||||
|         :size="16" | ||||
|       /> | ||||
|     </a> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -1,25 +1,23 @@ | |||
| <script> | ||||
|   import icon from '~/vue_shared/components/icon.vue'; | ||||
|   import tooltip from '~/vue_shared/directives/tooltip'; | ||||
|   import timeAgoMixin from '~/vue_shared/mixins/timeago'; | ||||
| import icon from '~/vue_shared/components/icon.vue'; | ||||
| import tooltip from '~/vue_shared/directives/tooltip'; | ||||
| import timeAgoMixin from '~/vue_shared/mixins/timeago'; | ||||
| 
 | ||||
|   export default { | ||||
| export default { | ||||
|   components: { | ||||
|     icon, | ||||
|   }, | ||||
|   directives: { | ||||
|     tooltip, | ||||
|   }, | ||||
|     mixins: [ | ||||
|       timeAgoMixin, | ||||
|     ], | ||||
|   mixins: [timeAgoMixin], | ||||
|   props: { | ||||
|     file: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   }; | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -50,7 +48,9 @@ | |||
|     <div class="text-right"> | ||||
|       {{ file.eol }} | ||||
|     </div> | ||||
|     <div class="text-right"> | ||||
|     <div | ||||
|       class="text-right" | ||||
|       v-if="!file.binary"> | ||||
|       {{ file.editorRow }}:{{ file.editorColumn }} | ||||
|     </div> | ||||
|     <div class="text-right"> | ||||
|  |  | |||
|  | @ -2,10 +2,16 @@ | |||
| /* global monaco */ | ||||
| import { mapState, mapGetters, mapActions } from 'vuex'; | ||||
| import flash from '~/flash'; | ||||
| import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; | ||||
| import monacoLoader from '../monaco_loader'; | ||||
| import Editor from '../lib/editor'; | ||||
| import IdeFileButtons from './ide_file_buttons.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     ContentViewer, | ||||
|     IdeFileButtons, | ||||
|   }, | ||||
|   props: { | ||||
|     file: { | ||||
|       type: Object, | ||||
|  | @ -13,27 +19,40 @@ export default { | |||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState(['leftPanelCollapsed', 'rightPanelCollapsed', 'viewer', 'delayViewerUpdated']), | ||||
|     ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), | ||||
|     ...mapGetters(['currentMergeRequest']), | ||||
|     shouldHideEditor() { | ||||
|       return this.file && this.file.binary && !this.file.raw; | ||||
|       return this.file && this.file.binary && !this.file.content; | ||||
|     }, | ||||
|     editTabCSS() { | ||||
|       return { | ||||
|         active: this.file.viewMode === 'edit', | ||||
|       }; | ||||
|     }, | ||||
|     previewTabCSS() { | ||||
|       return { | ||||
|         active: this.file.viewMode === 'preview', | ||||
|       }; | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     file(oldVal, newVal) { | ||||
|       if (newVal.path !== this.file.path) { | ||||
|       // Compare key to allow for files opened in review mode to be cached differently | ||||
|       if (newVal.key !== this.file.key) { | ||||
|         this.initMonaco(); | ||||
|       } | ||||
|     }, | ||||
|     leftPanelCollapsed() { | ||||
|       this.editor.updateDimensions(); | ||||
|     }, | ||||
|     rightPanelCollapsed() { | ||||
|       this.editor.updateDimensions(); | ||||
|     }, | ||||
|     viewer() { | ||||
|       this.createEditorInstance(); | ||||
|     }, | ||||
|     panelResizing() { | ||||
|       if (!this.panelResizing) { | ||||
|         this.editor.updateDimensions(); | ||||
|       } | ||||
|     }, | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.editor.dispose(); | ||||
|  | @ -55,6 +74,7 @@ export default { | |||
|       'changeFileContent', | ||||
|       'setFileLanguage', | ||||
|       'setEditorPosition', | ||||
|       'setFileViewMode', | ||||
|       'setFileEOL', | ||||
|       'updateViewer', | ||||
|       'updateDelayViewerUpdated', | ||||
|  | @ -70,7 +90,7 @@ export default { | |||
|       }) | ||||
|         .then(() => { | ||||
|           const viewerPromise = this.delayViewerUpdated | ||||
|             ? this.updateViewer('editor') | ||||
|             ? this.updateViewer(this.file.pending ? 'diff' : 'editor') | ||||
|             : Promise.resolve(); | ||||
| 
 | ||||
|           return viewerPromise; | ||||
|  | @ -151,16 +171,49 @@ export default { | |||
|     id="ide" | ||||
|     class="blob-viewer-container blob-editor-container" | ||||
|   > | ||||
|     <div | ||||
|       v-if="shouldHideEditor" | ||||
|       v-html="file.html" | ||||
|     > | ||||
|     <div class="ide-mode-tabs clearfix"> | ||||
|       <ul | ||||
|         class="nav-links pull-left" | ||||
|         v-if="!shouldHideEditor"> | ||||
|         <li :class="editTabCSS"> | ||||
|           <a | ||||
|             href="javascript:void(0);" | ||||
|             role="button" | ||||
|             @click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> | ||||
|             <template v-if="viewer === 'editor'"> | ||||
|               {{ __('Edit') }} | ||||
|             </template> | ||||
|             <template v-else> | ||||
|               {{ __('Review') }} | ||||
|             </template> | ||||
|           </a> | ||||
|         </li> | ||||
|         <li | ||||
|           v-if="file.previewMode" | ||||
|           :class="previewTabCSS"> | ||||
|           <a | ||||
|             href="javascript:void(0);" | ||||
|             role="button" | ||||
|             @click.prevent="setFileViewMode({ file, viewMode:'preview' })"> | ||||
|             {{ file.previewMode.previewTitle }} | ||||
|           </a> | ||||
|         </li> | ||||
|       </ul> | ||||
|       <ide-file-buttons | ||||
|         :file="file" | ||||
|       /> | ||||
|     </div> | ||||
|     <div | ||||
|       v-show="!shouldHideEditor" | ||||
|       v-show="!shouldHideEditor && file.viewMode === 'edit'" | ||||
|       ref="editor" | ||||
|       class="multi-file-editor-holder" | ||||
|     > | ||||
|     </div> | ||||
|     <content-viewer | ||||
|       v-if="shouldHideEditor || file.viewMode === 'preview'" | ||||
|       :content="file.content || file.raw" | ||||
|       :path="file.rawPath || file.path" | ||||
|       :file-size="file.size" | ||||
|       :project-path="file.projectId"/> | ||||
|   </div> | ||||
| </template> | ||||
|  |  | |||
|  | @ -62,11 +62,7 @@ export default { | |||
|         this.toggleTreeOpen(this.file.path); | ||||
|       } | ||||
| 
 | ||||
|       const delayPromise = this.file.changed | ||||
|         ? Promise.resolve() | ||||
|         : this.updateDelayViewerUpdated(true); | ||||
| 
 | ||||
|       return delayPromise.then(() => { | ||||
|       return this.updateDelayViewerUpdated(true).then(() => { | ||||
|         router.push(`/project${this.file.url}`); | ||||
|       }); | ||||
|     }, | ||||
|  |  | |||
|  | @ -1,61 +0,0 @@ | |||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     file: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     showButtons() { | ||||
|       return this.file.rawPath || | ||||
|         this.file.blamePath || | ||||
|         this.file.commitsPath || | ||||
|         this.file.permalink; | ||||
|     }, | ||||
|     rawDownloadButtonLabel() { | ||||
|       return this.file.binary ? 'Download' : 'Raw'; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div | ||||
|     v-if="showButtons" | ||||
|     class="multi-file-editor-btn-group" | ||||
|   > | ||||
|     <a | ||||
|       :href="file.rawPath" | ||||
|       target="_blank" | ||||
|       class="btn btn-default btn-sm raw" | ||||
|       rel="noopener noreferrer"> | ||||
|       {{ rawDownloadButtonLabel }} | ||||
|     </a> | ||||
| 
 | ||||
|     <div | ||||
|       class="btn-group" | ||||
|       role="group" | ||||
|       aria-label="File actions" | ||||
|     > | ||||
|       <a | ||||
|         :href="file.blamePath" | ||||
|         class="btn btn-default btn-sm blame" | ||||
|       > | ||||
|         Blame | ||||
|       </a> | ||||
|       <a | ||||
|         :href="file.commitsPath" | ||||
|         class="btn btn-default btn-sm history" | ||||
|       > | ||||
|         History | ||||
|       </a> | ||||
|       <a | ||||
|         :href="file.permalink" | ||||
|         class="btn btn-default btn-sm permalink" | ||||
|       > | ||||
|         Permalink | ||||
|       </a> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -1,17 +1,17 @@ | |||
| <script> | ||||
|   import { mapActions } from 'vuex'; | ||||
| import { mapActions } from 'vuex'; | ||||
| 
 | ||||
|   import fileIcon from '~/vue_shared/components/file_icon.vue'; | ||||
|   import icon from '~/vue_shared/components/icon.vue'; | ||||
|   import fileStatusIcon from './repo_file_status_icon.vue'; | ||||
|   import changedFileIcon from './changed_file_icon.vue'; | ||||
| import FileIcon from '~/vue_shared/components/file_icon.vue'; | ||||
| import Icon from '~/vue_shared/components/icon.vue'; | ||||
| import FileStatusIcon from './repo_file_status_icon.vue'; | ||||
| import ChangedFileIcon from './changed_file_icon.vue'; | ||||
| 
 | ||||
|   export default { | ||||
| export default { | ||||
|   components: { | ||||
|       fileStatusIcon, | ||||
|       fileIcon, | ||||
|       icon, | ||||
|       changedFileIcon, | ||||
|     FileStatusIcon, | ||||
|     FileIcon, | ||||
|     Icon, | ||||
|     ChangedFileIcon, | ||||
|   }, | ||||
|   props: { | ||||
|     tab: { | ||||
|  | @ -37,11 +37,15 @@ | |||
|   }, | ||||
| 
 | ||||
|   methods: { | ||||
|       ...mapActions([ | ||||
|         'closeFile', | ||||
|       ]), | ||||
|     ...mapActions(['closeFile', 'updateDelayViewerUpdated', 'openPendingTab']), | ||||
|     clickFile(tab) { | ||||
|       this.updateDelayViewerUpdated(true); | ||||
| 
 | ||||
|       if (tab.pending) { | ||||
|         this.openPendingTab(tab); | ||||
|       } else { | ||||
|         this.$router.push(`/project${tab.url}`); | ||||
|       } | ||||
|     }, | ||||
|     mouseOverTab() { | ||||
|       if (this.tab.changed) { | ||||
|  | @ -54,7 +58,7 @@ | |||
|       } | ||||
|     }, | ||||
|   }, | ||||
|   }; | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -66,7 +70,7 @@ | |||
|     <button | ||||
|       type="button" | ||||
|       class="multi-file-tab-close" | ||||
|       @click.stop.prevent="closeFile(tab.path)" | ||||
|       @click.stop.prevent="closeFile(tab)" | ||||
|       :aria-label="closeLabel" | ||||
|     > | ||||
|       <icon | ||||
|  | @ -82,7 +86,9 @@ | |||
| 
 | ||||
|     <div | ||||
|       class="multi-file-tab" | ||||
|       :class="{active : tab.active }" | ||||
|       :class="{ | ||||
|         active: tab.active | ||||
|       }" | ||||
|       :title="tab.url" | ||||
|     > | ||||
|       <file-icon | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ | |||
| import { mapActions } from 'vuex'; | ||||
| import RepoTab from './repo_tab.vue'; | ||||
| import EditorMode from './editor_mode_dropdown.vue'; | ||||
| import router from '../ide_router'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|  | @ -9,6 +10,10 @@ export default { | |||
|     EditorMode, | ||||
|   }, | ||||
|   props: { | ||||
|     activeFile: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|     files: { | ||||
|       type: Array, | ||||
|       required: true, | ||||
|  | @ -38,7 +43,18 @@ export default { | |||
|     this.showShadow = this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions(['updateViewer']), | ||||
|     ...mapActions(['updateViewer', 'removePendingTab']), | ||||
|     openFileViewer(viewer) { | ||||
|       this.updateViewer(viewer); | ||||
| 
 | ||||
|       if (this.activeFile.pending) { | ||||
|         return this.removePendingTab(this.activeFile).then(() => { | ||||
|           router.push(`/project${this.activeFile.url}`); | ||||
|         }); | ||||
|       } | ||||
| 
 | ||||
|       return null; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | @ -60,7 +76,7 @@ export default { | |||
|       :show-shadow="showShadow" | ||||
|       :has-changes="hasChanges" | ||||
|       :merge-request-id="mergeRequestId" | ||||
|       @click="updateViewer" | ||||
|       @click="openFileViewer" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| <script> | ||||
|   import { mapActions, mapState } from 'vuex'; | ||||
|   import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; | ||||
| import { mapActions, mapState } from 'vuex'; | ||||
| import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; | ||||
| 
 | ||||
|   export default { | ||||
| export default { | ||||
|   components: { | ||||
|     PanelResizer, | ||||
|   }, | ||||
|  | @ -18,7 +18,7 @@ | |||
|     minSize: { | ||||
|       type: Number, | ||||
|       required: false, | ||||
|         default: 200, | ||||
|       default: 340, | ||||
|     }, | ||||
|     side: { | ||||
|       type: String, | ||||
|  | @ -47,10 +47,7 @@ | |||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|       ...mapActions([ | ||||
|         'setPanelCollapsedStatus', | ||||
|         'setResizingStatus', | ||||
|       ]), | ||||
|     ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']), | ||||
|     toggleFullbarCollapsed() { | ||||
|       if (this.collapsed && this.collapsible) { | ||||
|         this.setPanelCollapsedStatus({ | ||||
|  | @ -60,8 +57,8 @@ | |||
|       } | ||||
|     }, | ||||
|   }, | ||||
|     maxSize: (window.innerWidth / 2), | ||||
|   }; | ||||
|   maxSize: window.innerWidth / 2, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -36,11 +36,11 @@ const router = new VueRouter({ | |||
|   base: `${gon.relative_url_root}/-/ide/`, | ||||
|   routes: [ | ||||
|     { | ||||
|       path: '/project/:namespace/:project', | ||||
|       path: '/project/:namespace/:project+', | ||||
|       component: EmptyRouterComponent, | ||||
|       children: [ | ||||
|         { | ||||
|           path: ':targetmode/:branch/*', | ||||
|           path: ':targetmode(edit|tree|blob)/:branch/*', | ||||
|           component: EmptyRouterComponent, | ||||
|         }, | ||||
|         { | ||||
|  | @ -77,7 +77,11 @@ router.beforeEach((to, from, next) => { | |||
|               if (to.params[0]) { | ||||
|                 const path = | ||||
|                   to.params[0].slice(-1) === '/' ? to.params[0].slice(0, -1) : to.params[0]; | ||||
|                 const treeEntry = store.state.entries[path]; | ||||
|                 const treeEntryKey = Object.keys(store.state.entries).find( | ||||
|                   key => key === path && !store.state.entries[key].pending, | ||||
|                 ); | ||||
|                 const treeEntry = store.state.entries[treeEntryKey]; | ||||
| 
 | ||||
|                 if (treeEntry) { | ||||
|                   store.dispatch('handleTreeEntryAction', treeEntry); | ||||
|                 } | ||||
|  |  | |||
|  | @ -13,12 +13,12 @@ export default class Model { | |||
|       (this.originalModel = this.monaco.editor.createModel( | ||||
|         this.file.raw, | ||||
|         undefined, | ||||
|         new this.monaco.Uri(null, null, `original/${this.file.path}`), | ||||
|         new this.monaco.Uri(null, null, `original/${this.file.key}`), | ||||
|       )), | ||||
|       (this.model = this.monaco.editor.createModel( | ||||
|         this.content, | ||||
|         undefined, | ||||
|         new this.monaco.Uri(null, null, this.file.path), | ||||
|         new this.monaco.Uri(null, null, this.file.key), | ||||
|       )), | ||||
|     ); | ||||
|     if (this.file.mrChange) { | ||||
|  | @ -36,7 +36,7 @@ export default class Model { | |||
|     this.updateContent = this.updateContent.bind(this); | ||||
|     this.dispose = this.dispose.bind(this); | ||||
| 
 | ||||
|     eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose); | ||||
|     eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose); | ||||
|     eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent); | ||||
|   } | ||||
| 
 | ||||
|  | @ -53,7 +53,7 @@ export default class Model { | |||
|   } | ||||
| 
 | ||||
|   get path() { | ||||
|     return this.file.path; | ||||
|     return this.file.key; | ||||
|   } | ||||
| 
 | ||||
|   getModel() { | ||||
|  | @ -88,7 +88,7 @@ export default class Model { | |||
|     this.disposable.dispose(); | ||||
|     this.events.clear(); | ||||
| 
 | ||||
|     eventHub.$off(`editor.update.model.dispose.${this.file.path}`, this.dispose); | ||||
|     eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); | ||||
|     eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -9,17 +9,17 @@ export default class ModelManager { | |||
|     this.models = new Map(); | ||||
|   } | ||||
| 
 | ||||
|   hasCachedModel(path) { | ||||
|     return this.models.has(path); | ||||
|   hasCachedModel(key) { | ||||
|     return this.models.has(key); | ||||
|   } | ||||
| 
 | ||||
|   getModel(path) { | ||||
|     return this.models.get(path); | ||||
|   getModel(key) { | ||||
|     return this.models.get(key); | ||||
|   } | ||||
| 
 | ||||
|   addModel(file) { | ||||
|     if (this.hasCachedModel(file.path)) { | ||||
|       return this.getModel(file.path); | ||||
|     if (this.hasCachedModel(file.key)) { | ||||
|       return this.getModel(file.key); | ||||
|     } | ||||
| 
 | ||||
|     const model = new Model(this.monaco, file); | ||||
|  | @ -27,7 +27,7 @@ export default class ModelManager { | |||
|     this.disposable.add(model); | ||||
| 
 | ||||
|     eventHub.$on( | ||||
|       `editor.update.model.dispose.${file.path}`, | ||||
|       `editor.update.model.dispose.${file.key}`, | ||||
|       this.removeCachedModel.bind(this, file), | ||||
|     ); | ||||
| 
 | ||||
|  | @ -35,12 +35,9 @@ export default class ModelManager { | |||
|   } | ||||
| 
 | ||||
|   removeCachedModel(file) { | ||||
|     this.models.delete(file.path); | ||||
|     this.models.delete(file.key); | ||||
| 
 | ||||
|     eventHub.$off( | ||||
|       `editor.update.model.dispose.${file.path}`, | ||||
|       this.removeCachedModel, | ||||
|     ); | ||||
|     eventHub.$off(`editor.update.model.dispose.${file.key}`, this.removeCachedModel); | ||||
|   } | ||||
| 
 | ||||
|   dispose() { | ||||
|  |  | |||
|  | @ -69,6 +69,7 @@ export default class Editor { | |||
|           occurrencesHighlight: false, | ||||
|           renderLineHighlight: 'none', | ||||
|           hideCursorInOverviewRuler: true, | ||||
|           renderSideBySide: Editor.renderSideBySide(domElement), | ||||
|         })), | ||||
|       ); | ||||
| 
 | ||||
|  | @ -81,7 +82,7 @@ export default class Editor { | |||
|   } | ||||
| 
 | ||||
|   attachModel(model) { | ||||
|     if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') { | ||||
|     if (this.isDiffEditorType) { | ||||
|       this.instance.setModel({ | ||||
|         original: model.getOriginalModel(), | ||||
|         modified: model.getModel(), | ||||
|  | @ -153,6 +154,7 @@ export default class Editor { | |||
| 
 | ||||
|   updateDimensions() { | ||||
|     this.instance.layout(); | ||||
|     this.updateDiffView(); | ||||
|   } | ||||
| 
 | ||||
|   setPosition({ lineNumber, column }) { | ||||
|  | @ -171,4 +173,20 @@ export default class Editor { | |||
| 
 | ||||
|     this.disposable.add(this.instance.onDidChangeCursorPosition(e => cb(this.instance, e))); | ||||
|   } | ||||
| 
 | ||||
|   updateDiffView() { | ||||
|     if (!this.isDiffEditorType) return; | ||||
| 
 | ||||
|     this.instance.updateOptions({ | ||||
|       renderSideBySide: Editor.renderSideBySide(this.instance.getDomNode()), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   get isDiffEditorType() { | ||||
|     return this.instance.getEditorType() === 'vs.editor.IDiffEditor'; | ||||
|   } | ||||
| 
 | ||||
|   static renderSideBySide(domElement) { | ||||
|     return domElement.offsetWidth >= 700; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ export const defaultEditorOptions = { | |||
|   minimap: { | ||||
|     enabled: false, | ||||
|   }, | ||||
|   wordWrap: 'bounded', | ||||
|   wordWrap: 'on', | ||||
| }; | ||||
| 
 | ||||
| export default [ | ||||
|  |  | |||
|  | @ -21,7 +21,7 @@ export const discardAllChanges = ({ state, commit, dispatch }) => { | |||
| }; | ||||
| 
 | ||||
| export const closeAllFiles = ({ state, dispatch }) => { | ||||
|   state.openFiles.forEach(file => dispatch('closeFile', file.path)); | ||||
|   state.openFiles.forEach(file => dispatch('closeFile', file)); | ||||
| }; | ||||
| 
 | ||||
| export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { | ||||
|  |  | |||
|  | @ -6,24 +6,34 @@ import * as types from '../mutation_types'; | |||
| import router from '../../ide_router'; | ||||
| import { setPageTitle } from '../utils'; | ||||
| 
 | ||||
| export const closeFile = ({ commit, state, getters, dispatch }, path) => { | ||||
|   const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path); | ||||
|   const file = state.entries[path]; | ||||
| export const closeFile = ({ commit, state, dispatch }, file) => { | ||||
|   const path = file.path; | ||||
|   const indexOfClosedFile = state.openFiles.findIndex(f => f.key === file.key); | ||||
|   const fileWasActive = file.active; | ||||
| 
 | ||||
|   if (file.pending) { | ||||
|     commit(types.REMOVE_PENDING_TAB, file); | ||||
|   } else { | ||||
|     commit(types.TOGGLE_FILE_OPEN, path); | ||||
|     commit(types.SET_FILE_ACTIVE, { path, active: false }); | ||||
|   } | ||||
| 
 | ||||
|   if (state.openFiles.length > 0 && fileWasActive) { | ||||
|     const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; | ||||
|     const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path]; | ||||
|     const nextFileToOpen = state.openFiles[nextIndexToOpen]; | ||||
| 
 | ||||
|     if (nextFileToOpen.pending) { | ||||
|       dispatch('updateViewer', 'diff'); | ||||
|       dispatch('openPendingTab', nextFileToOpen); | ||||
|     } else { | ||||
|       dispatch('updateDelayViewerUpdated', true); | ||||
|       router.push(`/project${nextFileToOpen.url}`); | ||||
|     } | ||||
|   } else if (!state.openFiles.length) { | ||||
|     router.push(`/project/${file.projectId}/tree/${file.branchId}/`); | ||||
|   } | ||||
| 
 | ||||
|   eventHub.$emit(`editor.update.model.dispose.${file.path}`); | ||||
|   eventHub.$emit(`editor.update.model.dispose.${file.key}`); | ||||
| }; | ||||
| 
 | ||||
| export const setFileActive = ({ commit, state, getters, dispatch }, path) => { | ||||
|  | @ -139,6 +149,10 @@ export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn | |||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { | ||||
|   commit(types.SET_FILE_VIEWMODE, { file, viewMode }); | ||||
| }; | ||||
| 
 | ||||
| export const discardFileChanges = ({ state, commit }, path) => { | ||||
|   const file = state.entries[path]; | ||||
| 
 | ||||
|  | @ -151,3 +165,23 @@ export const discardFileChanges = ({ state, commit }, path) => { | |||
| 
 | ||||
|   eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); | ||||
| }; | ||||
| 
 | ||||
| export const openPendingTab = ({ commit, getters, dispatch, state }, file) => { | ||||
|   if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') { | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   commit(types.ADD_PENDING_TAB, { file }); | ||||
| 
 | ||||
|   dispatch('scrollToTab'); | ||||
| 
 | ||||
|   router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`); | ||||
| 
 | ||||
|   return true; | ||||
| }; | ||||
| 
 | ||||
| export const removePendingTab = ({ commit }, file) => { | ||||
|   commit(types.REMOVE_PENDING_TAB, file); | ||||
| 
 | ||||
|   eventHub.$emit(`editor.update.model.dispose.${file.key}`); | ||||
| }; | ||||
|  |  | |||
|  | @ -37,9 +37,9 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => { | |||
|   const commitMsg = sprintf( | ||||
|     __('Your changes have been committed. Commit %{commitId} %{commitStats}'), | ||||
|     { | ||||
|       commitId: `<a href="${currentProject.web_url}/commit/${ | ||||
|       commitId: `<a href="${currentProject.web_url}/commit/${data.short_id}" class="commit-sha">${ | ||||
|         data.short_id | ||||
|       }" class="commit-sha">${data.short_id}</a>`, | ||||
|       }</a>`, | ||||
|       commitStats, | ||||
|     }, | ||||
|     false, | ||||
|  | @ -54,9 +54,7 @@ export const checkCommitStatus = ({ rootState }) => | |||
|     .then(({ data }) => { | ||||
|       const { id } = data.commit; | ||||
|       const selectedBranch = | ||||
|         rootState.projects[rootState.currentProjectId].branches[ | ||||
|           rootState.currentBranchId | ||||
|         ]; | ||||
|         rootState.projects[rootState.currentProjectId].branches[rootState.currentBranchId]; | ||||
| 
 | ||||
|       if (selectedBranch.workingReference !== id) { | ||||
|         return true; | ||||
|  | @ -135,32 +133,15 @@ export const updateFilesAfterCommit = ( | |||
| 
 | ||||
|   if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { | ||||
|     router.push( | ||||
|       `/project/${rootState.currentProjectId}/blob/${branch}/${ | ||||
|         rootGetters.activeFile.path | ||||
|       }`,
 | ||||
|       `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH); | ||||
| }; | ||||
| 
 | ||||
| export const commitChanges = ({ | ||||
|   commit, | ||||
|   state, | ||||
|   getters, | ||||
|   dispatch, | ||||
|   rootState, | ||||
| }) => { | ||||
| export const commitChanges = ({ commit, state, getters, dispatch, rootState }) => { | ||||
|   const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; | ||||
|   const payload = createCommitPayload( | ||||
|     getters.branchName, | ||||
|     newBranch, | ||||
|     state, | ||||
|     rootState, | ||||
|   ); | ||||
|   const getCommitStatus = newBranch | ||||
|     ? Promise.resolve(false) | ||||
|     : dispatch('checkCommitStatus'); | ||||
|   const payload = createCommitPayload(getters.branchName, newBranch, state, rootState); | ||||
|   const getCommitStatus = newBranch ? Promise.resolve(false) : dispatch('checkCommitStatus'); | ||||
| 
 | ||||
|   commit(types.UPDATE_LOADING, true); | ||||
| 
 | ||||
|  | @ -182,12 +163,16 @@ export const commitChanges = ({ | |||
| 
 | ||||
|       if (!data.short_id) { | ||||
|         flash(data.message, 'alert', document, null, false, true); | ||||
|         return; | ||||
|         return null; | ||||
|       } | ||||
| 
 | ||||
|       dispatch('setLastCommitMessage', data); | ||||
|       dispatch('updateCommitMessage', ''); | ||||
| 
 | ||||
|       return dispatch('updateFilesAfterCommit', { | ||||
|         data, | ||||
|         branch: getters.branchName, | ||||
|       }) | ||||
|         .then(() => { | ||||
|           if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) { | ||||
|             dispatch( | ||||
|               'redirectToUrl', | ||||
|  | @ -198,13 +183,10 @@ export const commitChanges = ({ | |||
|               ), | ||||
|               { root: true }, | ||||
|             ); | ||||
|       } else { | ||||
|         dispatch('updateFilesAfterCommit', { | ||||
|           data, | ||||
|           branch: getters.branchName, | ||||
|         }); | ||||
|           } | ||||
|         }) | ||||
|         .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)); | ||||
|     }) | ||||
|     .catch(err => { | ||||
|       let errMsg = __('Error committing changes. Please try again.'); | ||||
|       if (err.response.data && err.response.data.message) { | ||||
|  |  | |||
|  | @ -38,6 +38,7 @@ export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA'; | |||
| export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; | ||||
| export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; | ||||
| export const SET_FILE_POSITION = 'SET_FILE_POSITION'; | ||||
| export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE'; | ||||
| export const SET_FILE_EOL = 'SET_FILE_EOL'; | ||||
| export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; | ||||
| export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; | ||||
|  | @ -49,3 +50,6 @@ export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY'; | |||
| export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE'; | ||||
| export const UPDATE_VIEWER = 'UPDATE_VIEWER'; | ||||
| export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; | ||||
| 
 | ||||
| export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; | ||||
| export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; | ||||
|  |  | |||
|  | @ -5,6 +5,14 @@ export default { | |||
|     Object.assign(state.entries[path], { | ||||
|       active, | ||||
|     }); | ||||
| 
 | ||||
|     if (active && !state.entries[path].pending) { | ||||
|       Object.assign(state, { | ||||
|         openFiles: state.openFiles.map(f => | ||||
|           Object.assign(f, { active: f.pending ? false : f.active }), | ||||
|         ), | ||||
|       }); | ||||
|     } | ||||
|   }, | ||||
|   [types.TOGGLE_FILE_OPEN](state, path) { | ||||
|     Object.assign(state.entries[path], { | ||||
|  | @ -12,10 +20,14 @@ export default { | |||
|     }); | ||||
| 
 | ||||
|     if (state.entries[path].opened) { | ||||
|       state.openFiles.push(state.entries[path]); | ||||
|     } else { | ||||
|       Object.assign(state, { | ||||
|         openFiles: state.openFiles.filter(f => f.path !== path), | ||||
|         openFiles: state.openFiles.filter(f => f.path !== path).concat(state.entries[path]), | ||||
|       }); | ||||
|     } else { | ||||
|       const file = state.entries[path]; | ||||
| 
 | ||||
|       Object.assign(state, { | ||||
|         openFiles: state.openFiles.filter(f => f.key !== file.key), | ||||
|       }); | ||||
|     } | ||||
|   }, | ||||
|  | @ -30,6 +42,8 @@ export default { | |||
|       renderError: data.render_error, | ||||
|       raw: null, | ||||
|       baseRaw: null, | ||||
|       html: data.html, | ||||
|       size: data.size, | ||||
|     }); | ||||
|   }, | ||||
|   [types.SET_FILE_RAW_DATA](state, { file, raw }) { | ||||
|  | @ -71,6 +85,11 @@ export default { | |||
|       mrChange, | ||||
|     }); | ||||
|   }, | ||||
|   [types.SET_FILE_VIEWMODE](state, { file, viewMode }) { | ||||
|     Object.assign(state.entries[file.path], { | ||||
|       viewMode, | ||||
|     }); | ||||
|   }, | ||||
|   [types.DISCARD_FILE_CHANGES](state, path) { | ||||
|     Object.assign(state.entries[path], { | ||||
|       content: state.entries[path].raw, | ||||
|  | @ -92,4 +111,37 @@ export default { | |||
|       changed, | ||||
|     }); | ||||
|   }, | ||||
|   [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { | ||||
|     const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending); | ||||
|     let openFiles = state.openFiles.map(f => | ||||
|       Object.assign(f, { active: f.path === file.path, opened: false }), | ||||
|     ); | ||||
| 
 | ||||
|     if (!pendingTab) { | ||||
|       const openFile = openFiles.find(f => f.path === file.path); | ||||
| 
 | ||||
|       openFiles = openFiles.concat(openFile ? null : file).reduce((acc, f) => { | ||||
|         if (!f) return acc; | ||||
| 
 | ||||
|         if (f.path === file.path) { | ||||
|           return acc.concat({ | ||||
|             ...f, | ||||
|             active: true, | ||||
|             pending: true, | ||||
|             opened: true, | ||||
|             key: `${keyPrefix}-${f.key}`, | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         return acc.concat(f); | ||||
|       }, []); | ||||
|     } | ||||
| 
 | ||||
|     Object.assign(state, { openFiles }); | ||||
|   }, | ||||
|   [types.REMOVE_PENDING_TAB](state, file) { | ||||
|     Object.assign(state, { | ||||
|       openFiles: state.openFiles.filter(f => f.key !== file.key), | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| export const dataStructure = () => ({ | ||||
|   id: '', | ||||
|   // Key will contain a mixture of ID and path
 | ||||
|   // it can also contain a prefix `pending-` for files opened in review mode
 | ||||
|   key: '', | ||||
|   type: '', | ||||
|   projectId: '', | ||||
|  | @ -36,6 +38,9 @@ export const dataStructure = () => ({ | |||
|   editorColumn: 1, | ||||
|   fileLanguage: '', | ||||
|   eol: '', | ||||
|   viewMode: 'edit', | ||||
|   previewMode: null, | ||||
|   size: 0, | ||||
| }); | ||||
| 
 | ||||
| export const decorateData = entity => { | ||||
|  | @ -55,8 +60,9 @@ export const decorateData = entity => { | |||
|     changed = false, | ||||
|     parentTreeUrl = '', | ||||
|     base64 = false, | ||||
| 
 | ||||
|     previewMode, | ||||
|     file_lock, | ||||
|     html, | ||||
|   } = entity; | ||||
| 
 | ||||
|   return { | ||||
|  | @ -77,8 +83,9 @@ export const decorateData = entity => { | |||
|     renderError, | ||||
|     content, | ||||
|     base64, | ||||
| 
 | ||||
|     previewMode, | ||||
|     file_lock, | ||||
|     html, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,14 +1,8 @@ | |||
| import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; | ||||
| import { decorateData, sortTree } from '../utils'; | ||||
| 
 | ||||
| self.addEventListener('message', e => { | ||||
|   const { | ||||
|     data, | ||||
|     projectId, | ||||
|     branchId, | ||||
|     tempFile = false, | ||||
|     content = '', | ||||
|     base64 = false, | ||||
|   } = e.data; | ||||
|   const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; | ||||
| 
 | ||||
|   const treeList = []; | ||||
|   let file; | ||||
|  | @ -19,9 +13,7 @@ self.addEventListener('message', e => { | |||
|     if (pathSplit.length > 0) { | ||||
|       pathSplit.reduce((pathAcc, folderName) => { | ||||
|         const parentFolder = acc[pathAcc[pathAcc.length - 1]]; | ||||
|         const folderPath = `${ | ||||
|           parentFolder ? `${parentFolder.path}/` : '' | ||||
|         }${folderName}`;
 | ||||
|         const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`; | ||||
|         const foundEntry = acc[folderPath]; | ||||
| 
 | ||||
|         if (!foundEntry) { | ||||
|  | @ -33,9 +25,7 @@ self.addEventListener('message', e => { | |||
|             path: folderPath, | ||||
|             url: `/${projectId}/tree/${branchId}/${folderPath}/`, | ||||
|             type: 'tree', | ||||
|             parentTreeUrl: parentFolder | ||||
|               ? parentFolder.url | ||||
|               : `/${projectId}/tree/${branchId}/`, | ||||
|             parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, | ||||
|             tempFile, | ||||
|             changed: tempFile, | ||||
|             opened: tempFile, | ||||
|  | @ -70,13 +60,12 @@ self.addEventListener('message', e => { | |||
|         path, | ||||
|         url: `/${projectId}/blob/${branchId}/${path}`, | ||||
|         type: 'blob', | ||||
|         parentTreeUrl: fileFolder | ||||
|           ? fileFolder.url | ||||
|           : `/${projectId}/blob/${branchId}`, | ||||
|         parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, | ||||
|         tempFile, | ||||
|         changed: tempFile, | ||||
|         content, | ||||
|         base64, | ||||
|         previewMode: viewerInformationForPath(blobName), | ||||
|       }); | ||||
| 
 | ||||
|       Object.assign(acc, { | ||||
|  |  | |||
|  | @ -45,7 +45,7 @@ | |||
|         return `#${this.job.runner.id}`; | ||||
|       }, | ||||
|       hasTimeout() { | ||||
|         return this.job.metadata != null && this.job.metadata.timeout_human_readable !== ''; | ||||
|         return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; | ||||
|       }, | ||||
|       timeout() { | ||||
|         if (this.job.metadata == null) { | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; | |||
| import DropdownUtils from './filtered_search/dropdown_utils'; | ||||
| import CreateLabelDropdown from './create_label'; | ||||
| import flash from './flash'; | ||||
| import ModalStore from './boards/stores/modal_store'; | ||||
| 
 | ||||
| export default class LabelsSelect { | ||||
|   constructor(els, options = {}) { | ||||
|  | @ -350,7 +351,7 @@ export default class LabelsSelect { | |||
|           } | ||||
| 
 | ||||
|           if ($dropdown.closest('.add-issues-modal').length) { | ||||
|             boardsModel = gl.issueBoards.ModalStore.store.filter; | ||||
|             boardsModel = ModalStore.store.filter; | ||||
|           } | ||||
| 
 | ||||
|           if (boardsModel) { | ||||
|  |  | |||
|  | @ -33,6 +33,7 @@ export const checkPageAndAction = (page, action) => { | |||
| 
 | ||||
| export const isInIssuePage = () => checkPageAndAction('issues', 'show'); | ||||
| export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); | ||||
| export const isInEpicPage = () => checkPageAndAction('epics', 'show'); | ||||
| export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); | ||||
| export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,7 +1,12 @@ | |||
| /* eslint-disable import/prefer-default-export */ | ||||
| import $ from 'jquery'; | ||||
| import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils'; | ||||
| 
 | ||||
| const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); | ||||
| 
 | ||||
| export const addClassIfElementExists = (element, className) => { | ||||
|   if (element) { | ||||
|     element.classList.add(className); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions(); | ||||
|  |  | |||
|  | @ -7,7 +7,8 @@ | |||
|  * @param {String} text | ||||
|  * @returns {String} | ||||
|  */ | ||||
| export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); | ||||
| export const addDelimiter = text => | ||||
|   (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); | ||||
| 
 | ||||
| /** | ||||
|  * Returns '99+' for numbers bigger than 99. | ||||
|  | @ -22,7 +23,8 @@ export const highCountTrim = count => (count > 99 ? '99+' : count); | |||
|  * @param {String} string | ||||
|  * @requires {String} | ||||
|  */ | ||||
| export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); | ||||
| export const humanize = string => | ||||
|   string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); | ||||
| 
 | ||||
| /** | ||||
|  * Adds an 's' to the end of the string when count is bigger than 0 | ||||
|  | @ -53,7 +55,7 @@ export const slugify = str => str.trim().toLowerCase(); | |||
|  * @param {Number} maxLength | ||||
|  * @returns {String} | ||||
|  */ | ||||
| export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`; | ||||
| export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; | ||||
| 
 | ||||
| /** | ||||
|  * Capitalizes first character | ||||
|  | @ -80,3 +82,15 @@ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, re | |||
|  * @param {*} string | ||||
|  */ | ||||
| export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); | ||||
| 
 | ||||
| /** | ||||
|  * Converts a sentence to lower case from the second word onwards | ||||
|  * e.g. Hello World => Hello world | ||||
|  * | ||||
|  * @param {*} string | ||||
|  */ | ||||
| export const convertToSentenceCase = string => { | ||||
|   const splitWord = string.split(' ').map((word, index) => (index > 0 ? word.toLowerCase() : word)); | ||||
| 
 | ||||
|   return splitWord.join(' '); | ||||
| }; | ||||
|  |  | |||
|  | @ -7,11 +7,7 @@ import flash from './flash'; | |||
| import BlobForkSuggestion from './blob/blob_fork_suggestion'; | ||||
| import initChangesDropdown from './init_changes_dropdown'; | ||||
| import bp from './breakpoints'; | ||||
| import { | ||||
|   parseUrlPathname, | ||||
|   handleLocationHash, | ||||
|   isMetaClick, | ||||
| } from './lib/utils/common_utils'; | ||||
| import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils'; | ||||
| import { getLocationHash } from './lib/utils/url_utility'; | ||||
| import initDiscussionTab from './image_diff/init_discussion_tab'; | ||||
| import Diff from './diff'; | ||||
|  | @ -69,11 +65,10 @@ import Notes from './notes'; | |||
| let location = window.location; | ||||
| 
 | ||||
| export default class MergeRequestTabs { | ||||
| 
 | ||||
|   constructor({ action, setUrl, stubLocation } = {}) { | ||||
|     const mergeRequestTabs = document.querySelector('.js-tabs-affix'); | ||||
|     const navbar = document.querySelector('.navbar-gitlab'); | ||||
|     const peek = document.getElementById('peek'); | ||||
|     const peek = document.getElementById('js-peek'); | ||||
|     const paddingTop = 16; | ||||
| 
 | ||||
|     this.diffsLoaded = false; | ||||
|  | @ -109,8 +104,7 @@ export default class MergeRequestTabs { | |||
|       .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) | ||||
|       .on('click', '.js-show-tab', this.showTab); | ||||
| 
 | ||||
|     $('.merge-request-tabs a[data-toggle="tab"]') | ||||
|       .on('click', this.clickTab); | ||||
|     $('.merge-request-tabs a[data-toggle="tab"]').on('click', this.clickTab); | ||||
|   } | ||||
| 
 | ||||
|   // Used in tests
 | ||||
|  | @ -119,8 +113,7 @@ export default class MergeRequestTabs { | |||
|       .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) | ||||
|       .off('click', '.js-show-tab', this.showTab); | ||||
| 
 | ||||
|     $('.merge-request-tabs a[data-toggle="tab"]') | ||||
|       .off('click', this.clickTab); | ||||
|     $('.merge-request-tabs a[data-toggle="tab"]').off('click', this.clickTab); | ||||
|   } | ||||
| 
 | ||||
|   destroyPipelinesView() { | ||||
|  | @ -183,10 +176,7 @@ export default class MergeRequestTabs { | |||
| 
 | ||||
|   scrollToElement(container) { | ||||
|     if (location.hash) { | ||||
|       const offset = 0 - ( | ||||
|         $('.navbar-gitlab').outerHeight() + | ||||
|         $('.js-tabs-affix').outerHeight() | ||||
|       ); | ||||
|       const offset = 0 - ($('.navbar-gitlab').outerHeight() + $('.js-tabs-affix').outerHeight()); | ||||
|       const $el = $(`${container} ${location.hash}:not(.match)`); | ||||
|       if ($el.length) { | ||||
|         $.scrollTo($el[0], { offset }); | ||||
|  | @ -240,9 +230,13 @@ export default class MergeRequestTabs { | |||
|     // Turbolinks' history.
 | ||||
|     //
 | ||||
|     // See https://github.com/rails/turbolinks/issues/363
 | ||||
|     window.history.replaceState({ | ||||
|     window.history.replaceState( | ||||
|       { | ||||
|         url: newState, | ||||
|     }, document.title, newState); | ||||
|       }, | ||||
|       document.title, | ||||
|       newState, | ||||
|     ); | ||||
| 
 | ||||
|     return newState; | ||||
|   } | ||||
|  | @ -258,7 +252,8 @@ export default class MergeRequestTabs { | |||
| 
 | ||||
|     this.toggleLoading(true); | ||||
| 
 | ||||
|     axios.get(`${source}.json`) | ||||
|     axios | ||||
|       .get(`${source}.json`) | ||||
|       .then(({ data }) => { | ||||
|         document.querySelector('div#commits').innerHTML = data.html; | ||||
|         localTimeAgo($('.js-timeago', 'div#commits')); | ||||
|  | @ -303,7 +298,8 @@ export default class MergeRequestTabs { | |||
| 
 | ||||
|     this.toggleLoading(true); | ||||
| 
 | ||||
|     axios.get(`${urlPathname}.json${location.search}`) | ||||
|     axios | ||||
|       .get(`${urlPathname}.json${location.search}`) | ||||
|       .then(({ data }) => { | ||||
|         const $container = $('#diffs'); | ||||
|         $container.html(data.html); | ||||
|  | @ -332,8 +328,7 @@ export default class MergeRequestTabs { | |||
|             cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'), | ||||
|             suggestionSections: $(el).find('.js-file-fork-suggestion-section'), | ||||
|             actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'), | ||||
|           }) | ||||
|             .init(); | ||||
|           }).init(); | ||||
|         }); | ||||
| 
 | ||||
|         // Scroll any linked note into view
 | ||||
|  | @ -388,8 +383,7 @@ export default class MergeRequestTabs { | |||
| 
 | ||||
|   resetViewContainer() { | ||||
|     if (this.fixedLayoutPref !== null) { | ||||
|       $('.content-wrapper .container-fluid') | ||||
|         .toggleClass('container-limited', this.fixedLayoutPref); | ||||
|       $('.content-wrapper .container-fluid').toggleClass('container-limited', this.fixedLayoutPref); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -438,12 +432,11 @@ export default class MergeRequestTabs { | |||
| 
 | ||||
|     const $diffTabs = $('#diff-notes-app'); | ||||
| 
 | ||||
|     $tabs.off('affix.bs.affix affix-top.bs.affix') | ||||
|     $tabs | ||||
|       .off('affix.bs.affix affix-top.bs.affix') | ||||
|       .affix({ | ||||
|         offset: { | ||||
|           top: () => ( | ||||
|             $diffTabs.offset().top - $tabs.height() - $fixedNav.height() | ||||
|           ), | ||||
|           top: () => $diffTabs.offset().top - $tabs.height() - $fixedNav.height(), | ||||
|         }, | ||||
|       }) | ||||
|       .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() })) | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import $ from 'jquery'; | ||||
| import axios from './lib/utils/axios_utils'; | ||||
| import flash from './flash'; | ||||
| import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover'; | ||||
| 
 | ||||
| export default class Milestone { | ||||
|   constructor() { | ||||
|  | @ -43,4 +44,25 @@ export default class Milestone { | |||
|         .catch(() => flash('Error loading milestone tab')); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   static initDeprecationMessage() { | ||||
|     const deprecationMesssageContainer = document.querySelector('.js-milestone-deprecation-message'); | ||||
| 
 | ||||
|     if (!deprecationMesssageContainer) return; | ||||
| 
 | ||||
|     const deprecationMessage = deprecationMesssageContainer.querySelector('.js-milestone-deprecation-message-template').innerHTML; | ||||
|     const $popover = $('.js-popover-link', deprecationMesssageContainer); | ||||
|     const hideOnScroll = togglePopover.bind($popover, false); | ||||
| 
 | ||||
|     $popover.popover({ | ||||
|       content: deprecationMessage, | ||||
|       html: true, | ||||
|       placement: 'bottom', | ||||
|     }) | ||||
|     .on('mouseenter', mouseenter) | ||||
|     .on('mouseleave', debouncedMouseleave()) | ||||
|     .on('show.bs.popover', () => { | ||||
|       window.addEventListener('scroll', hideOnScroll, { once: true }); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ import $ from 'jquery'; | |||
| import _ from 'underscore'; | ||||
| import axios from './lib/utils/axios_utils'; | ||||
| import { timeFor } from './lib/utils/datetime_utility'; | ||||
| import ModalStore from './boards/stores/modal_store'; | ||||
| 
 | ||||
| export default class MilestoneSelect { | ||||
|   constructor(currentProject, els, options = {}) { | ||||
|  | @ -94,10 +95,10 @@ export default class MilestoneSelect { | |||
|             if (showMenuAbove) { | ||||
|               $dropdown.data('glDropdown').positionMenuAbove(); | ||||
|             } | ||||
|             $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); | ||||
|             $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active'); | ||||
|           }), | ||||
|         renderRow: milestone => ` | ||||
|           <li data-milestone-id="${milestone.name}"> | ||||
|           <li data-milestone-id="${_.escape(milestone.name)}"> | ||||
|             <a href='#' class='dropdown-menu-milestone-link'> | ||||
|               ${_.escape(milestone.title)} | ||||
|             </a> | ||||
|  | @ -125,7 +126,6 @@ export default class MilestoneSelect { | |||
|             return milestone.id; | ||||
|           } | ||||
|         }, | ||||
|         isSelected: milestone => milestone.name === selectedMilestone, | ||||
|         hidden: () => { | ||||
|           $selectBox.hide(); | ||||
|           // display:block overrides the hide-collapse rule
 | ||||
|  | @ -137,7 +137,7 @@ export default class MilestoneSelect { | |||
|             selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; | ||||
|           } | ||||
|           $('a.is-active', $el).removeClass('is-active'); | ||||
|           $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); | ||||
|           $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); | ||||
|         }, | ||||
|         vue: $dropdown.hasClass('js-issue-board-sidebar'), | ||||
|         clicked: (clickEvent) => { | ||||
|  | @ -158,13 +158,14 @@ export default class MilestoneSelect { | |||
|           const isMRIndex = (page === page && page === 'projects:merge_requests:index'); | ||||
|           const isSelecting = (selected.name !== selectedMilestone); | ||||
|           selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault; | ||||
| 
 | ||||
|           if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { | ||||
|             e.preventDefault(); | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           if ($dropdown.closest('.add-issues-modal').length) { | ||||
|             boardsStore = gl.issueBoards.ModalStore.store.filter; | ||||
|             boardsStore = ModalStore.store.filter; | ||||
|           } | ||||
| 
 | ||||
|           if (boardsStore) { | ||||
|  |  | |||
|  | @ -1,8 +1,10 @@ | |||
| <script> | ||||
| import { scaleLinear, scaleTime } from 'd3-scale'; | ||||
| import { axisLeft, axisBottom } from 'd3-axis'; | ||||
| import _ from 'underscore'; | ||||
| import { max, extent } from 'd3-array'; | ||||
| import { select } from 'd3-selection'; | ||||
| import GraphAxis from './graph/axis.vue'; | ||||
| import GraphLegend from './graph/legend.vue'; | ||||
| import GraphFlag from './graph/flag.vue'; | ||||
| import GraphDeployment from './graph/deployment.vue'; | ||||
|  | @ -18,10 +20,11 @@ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select } | |||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GraphLegend, | ||||
|     GraphAxis, | ||||
|     GraphFlag, | ||||
|     GraphDeployment, | ||||
|     GraphPath, | ||||
|     GraphLegend, | ||||
|   }, | ||||
|   mixins: [MonitoringMixin], | ||||
|   props: { | ||||
|  | @ -138,7 +141,7 @@ export default { | |||
|       this.legendTitle = query.label || 'Average'; | ||||
|       this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right; | ||||
|       this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; | ||||
|       this.baseGraphHeight = this.graphHeight; | ||||
|       this.baseGraphHeight = this.graphHeight - 50; | ||||
|       this.baseGraphWidth = this.graphWidth; | ||||
| 
 | ||||
|       // pixel offsets inside the svg and outside are not 1:1 | ||||
|  | @ -177,10 +180,8 @@ export default { | |||
|         this.graphHeightOffset, | ||||
|       ); | ||||
| 
 | ||||
|       if (!this.showLegend) { | ||||
|         this.baseGraphHeight -= 50; | ||||
|       } else if (this.timeSeries.length > 3) { | ||||
|         this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; | ||||
|       if (_.findWhere(this.timeSeries, { renderCanary: true })) { | ||||
|         this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true })); | ||||
|       } | ||||
| 
 | ||||
|       const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); | ||||
|  | @ -251,17 +252,13 @@ export default { | |||
|           class="y-axis" | ||||
|           transform="translate(70, 20)" | ||||
|         /> | ||||
|         <graph-legend | ||||
|         <graph-axis | ||||
|           :graph-width="graphWidth" | ||||
|           :graph-height="graphHeight" | ||||
|           :margin="margin" | ||||
|           :measurements="measurements" | ||||
|           :legend-title="legendTitle" | ||||
|           :y-axis-label="yAxisLabel" | ||||
|           :time-series="timeSeries" | ||||
|           :unit-of-display="unitOfDisplay" | ||||
|           :current-data-index="currentDataIndex" | ||||
|           :show-legend-group="showLegend" | ||||
|         /> | ||||
|         <svg | ||||
|           class="graph-data" | ||||
|  | @ -306,5 +303,10 @@ export default { | |||
|         :deployment-flag-data="deploymentFlagData" | ||||
|       /> | ||||
|     </div> | ||||
|     <graph-legend | ||||
|       v-if="showLegend" | ||||
|       :legend-title="legendTitle" | ||||
|       :time-series="timeSeries" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  |  | |||
|  | @ -0,0 +1,142 @@ | |||
| <script> | ||||
| import { convertToSentenceCase } from '~/lib/utils/text_utility'; | ||||
| import { s__ } from '~/locale'; | ||||
| 
 | ||||
| export default { | ||||
|   props: { | ||||
|     graphWidth: { | ||||
|       type: Number, | ||||
|       required: true, | ||||
|     }, | ||||
|     graphHeight: { | ||||
|       type: Number, | ||||
|       required: true, | ||||
|     }, | ||||
|     margin: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|     measurements: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|     yAxisLabel: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     unitOfDisplay: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       yLabelWidth: 0, | ||||
|       yLabelHeight: 0, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     textTransform() { | ||||
|       const yCoordinate = | ||||
|         (this.graphHeight - | ||||
|           this.margin.top + | ||||
|           this.measurements.axisLabelLineOffset) / | ||||
|           2 || 0; | ||||
| 
 | ||||
|       return `translate(15, ${yCoordinate}) rotate(-90)`; | ||||
|     }, | ||||
| 
 | ||||
|     rectTransform() { | ||||
|       const yCoordinate = | ||||
|         (this.graphHeight - | ||||
|           this.margin.top + | ||||
|           this.measurements.axisLabelLineOffset) / | ||||
|           2 + | ||||
|           this.yLabelWidth / 2 || 0; | ||||
| 
 | ||||
|       return `translate(0, ${yCoordinate}) rotate(-90)`; | ||||
|     }, | ||||
| 
 | ||||
|     xPosition() { | ||||
|       return ( | ||||
|         (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - | ||||
|           this.margin.right || 0 | ||||
|       ); | ||||
|     }, | ||||
| 
 | ||||
|     yPosition() { | ||||
|       return ( | ||||
|         this.graphHeight - | ||||
|           this.margin.top + | ||||
|           this.measurements.axisLabelLineOffset || 0 | ||||
|       ); | ||||
|     }, | ||||
| 
 | ||||
|     yAxisLabelSentenceCase() { | ||||
|       return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`; | ||||
|     }, | ||||
| 
 | ||||
|     timeString() { | ||||
|       return s__('PrometheusDashboard|Time'); | ||||
|     }, | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.$nextTick(() => { | ||||
|       const bbox = this.$refs.ylabel.getBBox(); | ||||
|       this.yLabelWidth = bbox.width + 10; // Added some padding | ||||
|       this.yLabelHeight = bbox.height + 5; | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <g class="axis-label-container"> | ||||
|     <line | ||||
|       class="label-x-axis-line" | ||||
|       stroke="#000000" | ||||
|       stroke-width="1" | ||||
|       x1="10" | ||||
|       :y1="yPosition" | ||||
|       :x2="graphWidth + 20" | ||||
|       :y2="yPosition" | ||||
|     /> | ||||
|     <line | ||||
|       class="label-y-axis-line" | ||||
|       stroke="#000000" | ||||
|       stroke-width="1" | ||||
|       x1="10" | ||||
|       y1="0" | ||||
|       :x2="10" | ||||
|       :y2="yPosition" | ||||
|     /> | ||||
|     <rect | ||||
|       class="rect-axis-text" | ||||
|       :transform="rectTransform" | ||||
|       :width="yLabelWidth" | ||||
|       :height="yLabelHeight" | ||||
|     /> | ||||
|     <text | ||||
|       class="label-axis-text y-label-text" | ||||
|       text-anchor="middle" | ||||
|       :transform="textTransform" | ||||
|       ref="ylabel" | ||||
|     > | ||||
|       {{ yAxisLabelSentenceCase }} | ||||
|     </text> | ||||
|     <rect | ||||
|       class="rect-axis-text" | ||||
|       :x="xPosition + 60" | ||||
|       :y="graphHeight - 80" | ||||
|       width="35" | ||||
|       height="50" | ||||
|     /> | ||||
|     <text | ||||
|       class="label-axis-text x-label-text" | ||||
|       :x="xPosition + 60" | ||||
|       :y="yPosition" | ||||
|       dy=".35em" | ||||
|     > | ||||
|       {{ timeString }} | ||||
|     </text> | ||||
|   </g> | ||||
| </template> | ||||
|  | @ -1,11 +1,13 @@ | |||
| <script> | ||||
| import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; | ||||
| import { formatRelevantDigits } from '../../../lib/utils/number_utils'; | ||||
| import icon from '../../../vue_shared/components/icon.vue'; | ||||
| import Icon from '../../../vue_shared/components/icon.vue'; | ||||
| import TrackLine from './track_line.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     icon, | ||||
|     Icon, | ||||
|     TrackLine, | ||||
|   }, | ||||
|   props: { | ||||
|     currentXCoordinate: { | ||||
|  | @ -107,11 +109,6 @@ export default { | |||
|       } | ||||
|       return `series ${index + 1}`; | ||||
|     }, | ||||
|     strokeDashArray(type) { | ||||
|       if (type === 'dashed') return '6, 3'; | ||||
|       if (type === 'dotted') return '3, 3'; | ||||
|       return null; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | @ -160,28 +157,13 @@ export default { | |||
|         </div> | ||||
|       </div> | ||||
|       <div class="popover-content"> | ||||
|         <table> | ||||
|         <table class="prometheus-table"> | ||||
|           <tr | ||||
|             v-for="(series, index) in timeSeries" | ||||
|             :key="index" | ||||
|           > | ||||
|             <td> | ||||
|               <svg | ||||
|                 width="15" | ||||
|                 height="6" | ||||
|               > | ||||
|                 <line | ||||
|                   :stroke="series.lineColor" | ||||
|                   :stroke-dasharray="strokeDashArray(series.lineStyle)" | ||||
|                   stroke-width="4" | ||||
|                   x1="0" | ||||
|                   x2="15" | ||||
|                   y1="2" | ||||
|                   y2="2" | ||||
|                 /> | ||||
|               </svg> | ||||
|             </td> | ||||
|             <td>{{ seriesMetricLabel(index, series) }}</td> | ||||
|             <track-line :track="series"/> | ||||
|             <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td> | ||||
|             <td> | ||||
|               <strong>{{ seriesMetricValue(series) }}</strong> | ||||
|             </td> | ||||
|  |  | |||
|  | @ -1,204 +1,72 @@ | |||
| <script> | ||||
| import { formatRelevantDigits } from '../../../lib/utils/number_utils'; | ||||
| import TrackLine from './track_line.vue'; | ||||
| import TrackInfo from './track_info.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     TrackLine, | ||||
|     TrackInfo, | ||||
|   }, | ||||
|   props: { | ||||
|     graphWidth: { | ||||
|       type: Number, | ||||
|       required: true, | ||||
|     }, | ||||
|     graphHeight: { | ||||
|       type: Number, | ||||
|       required: true, | ||||
|     }, | ||||
|     margin: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|     measurements: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|     legendTitle: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     yAxisLabel: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     timeSeries: { | ||||
|       type: Array, | ||||
|       required: true, | ||||
|     }, | ||||
|     unitOfDisplay: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     currentDataIndex: { | ||||
|       type: Number, | ||||
|       required: true, | ||||
|     }, | ||||
|     showLegendGroup: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: true, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       yLabelWidth: 0, | ||||
|       yLabelHeight: 0, | ||||
|       seriesXPosition: 0, | ||||
|       metricUsageXPosition: 0, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     textTransform() { | ||||
|       const yCoordinate = | ||||
|         (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0; | ||||
| 
 | ||||
|       return `translate(15, ${yCoordinate}) rotate(-90)`; | ||||
|     }, | ||||
|     rectTransform() { | ||||
|       const yCoordinate = | ||||
|         (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 + | ||||
|           this.yLabelWidth / 2 || 0; | ||||
| 
 | ||||
|       return `translate(0, ${yCoordinate}) rotate(-90)`; | ||||
|     }, | ||||
|     xPosition() { | ||||
|       return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0; | ||||
|     }, | ||||
|     yPosition() { | ||||
|       return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0; | ||||
|     }, | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.$nextTick(() => { | ||||
|       const bbox = this.$refs.ylabel.getBBox(); | ||||
|       this.metricUsageXPosition = 0; | ||||
|       this.seriesXPosition = 0; | ||||
|       if (this.$refs.legendTitleSvg != null) { | ||||
|         this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width; | ||||
|       } | ||||
|       if (this.$refs.seriesTitleSvg != null) { | ||||
|         this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width; | ||||
|       } | ||||
|       this.yLabelWidth = bbox.width + 10; // Added some padding | ||||
|       this.yLabelHeight = bbox.height + 5; | ||||
|     }); | ||||
|   }, | ||||
|   methods: { | ||||
|     translateLegendGroup(index) { | ||||
|       return `translate(0, ${12 * index})`; | ||||
|     }, | ||||
|     formatMetricUsage(series) { | ||||
|       const value = | ||||
|         series.values[this.currentDataIndex] && series.values[this.currentDataIndex].value; | ||||
|       if (isNaN(value)) { | ||||
|         return '-'; | ||||
|       } | ||||
|       return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`; | ||||
|     }, | ||||
|     createSeriesString(index, series) { | ||||
|       if (series.metricTag) { | ||||
|         return `${series.metricTag} ${this.formatMetricUsage(series)}`; | ||||
|       } | ||||
|       return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`; | ||||
|     }, | ||||
|     strokeDashArray(type) { | ||||
|       if (type === 'dashed') return '6, 3'; | ||||
|       if (type === 'dotted') return '3, 3'; | ||||
|       return null; | ||||
|     isStable(track) { | ||||
|       return { | ||||
|         'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary, | ||||
|       }; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <g class="axis-label-container"> | ||||
|     <line | ||||
|       class="label-x-axis-line" | ||||
|       stroke="#000000" | ||||
|       stroke-width="1" | ||||
|       x1="10" | ||||
|       :y1="yPosition" | ||||
|       :x2="graphWidth + 20" | ||||
|       :y2="yPosition" | ||||
|     /> | ||||
|     <line | ||||
|       class="label-y-axis-line" | ||||
|       stroke="#000000" | ||||
|       stroke-width="1" | ||||
|       x1="10" | ||||
|       y1="0" | ||||
|       :x2="10" | ||||
|       :y2="yPosition" | ||||
|     /> | ||||
|     <rect | ||||
|       class="rect-axis-text" | ||||
|       :transform="rectTransform" | ||||
|       :width="yLabelWidth" | ||||
|       :height="yLabelHeight" | ||||
|     /> | ||||
|     <text | ||||
|       class="label-axis-text y-label-text" | ||||
|       text-anchor="middle" | ||||
|       :transform="textTransform" | ||||
|       ref="ylabel" | ||||
|     > | ||||
|       {{ yAxisLabel }} | ||||
|     </text> | ||||
|     <rect | ||||
|       class="rect-axis-text" | ||||
|       :x="xPosition + 60" | ||||
|       :y="graphHeight - 80" | ||||
|       width="35" | ||||
|       height="50" | ||||
|     /> | ||||
|     <text | ||||
|       class="label-axis-text x-label-text" | ||||
|       :x="xPosition + 60" | ||||
|       :y="yPosition" | ||||
|       dy=".35em" | ||||
|     > | ||||
|       Time | ||||
|     </text> | ||||
|     <template v-if="showLegendGroup"> | ||||
|       <g | ||||
|         class="legend-group" | ||||
|   <div class="prometheus-graph-legends prepend-left-10"> | ||||
|     <table class="prometheus-table"> | ||||
|       <tr | ||||
|         v-for="(series, index) in timeSeries" | ||||
|         :key="index" | ||||
|         :transform="translateLegendGroup(index)" | ||||
|         v-if="series.shouldRenderLegend" | ||||
|         :class="isStable(series)" | ||||
|       > | ||||
|         <line | ||||
|           :stroke="series.lineColor" | ||||
|           :stroke-width="measurements.legends.height" | ||||
|           :stroke-dasharray="strokeDashArray(series.lineStyle)" | ||||
|           :x1="measurements.legends.offsetX" | ||||
|           :x2="measurements.legends.offsetX + measurements.legends.width" | ||||
|           :y1="graphHeight - measurements.legends.offsetY" | ||||
|           :y2="graphHeight - measurements.legends.offsetY" | ||||
|         /> | ||||
|         <text | ||||
|           v-if="timeSeries.length > 1" | ||||
|         <td> | ||||
|           <strong v-if="series.renderCanary">{{ series.trackName }}</strong> | ||||
|         </td> | ||||
|         <track-line :track="series" /> | ||||
|         <td | ||||
|           class="legend-metric-title" | ||||
|           ref="legendTitleSvg" | ||||
|           x="38" | ||||
|           :y="graphHeight - 30" | ||||
|         > | ||||
|           {{ createSeriesString(index, series) }} | ||||
|         </text> | ||||
|         <text | ||||
|           v-if="timeSeries.length > 1"> | ||||
|           <track-info | ||||
|             :track="series" | ||||
|             v-if="series.metricTag" /> | ||||
|           <track-info | ||||
|             v-else | ||||
|             :track="series"> | ||||
|             <strong>{{ legendTitle }}</strong> series {{ index + 1 }} | ||||
|           </track-info> | ||||
|         </td> | ||||
|         <td v-else> | ||||
|           <track-info :track="series"> | ||||
|             <strong>{{ legendTitle }}</strong> | ||||
|           </track-info> | ||||
|         </td> | ||||
|         <template v-for="(track, trackIndex) in series.tracksLegend"> | ||||
|           <track-line | ||||
|             :track="track" | ||||
|             :key="`track-line-${trackIndex}`"/> | ||||
|           <td :key="`track-info-${trackIndex}`"> | ||||
|             <track-info | ||||
|               class="legend-metric-title" | ||||
|           ref="legendTitleSvg" | ||||
|           x="38" | ||||
|           :y="graphHeight - 30" | ||||
|         > | ||||
|           {{ legendTitle }} {{ formatMetricUsage(series) }} | ||||
|         </text> | ||||
|       </g> | ||||
|               :track="track" /> | ||||
|           </td> | ||||
|         </template> | ||||
|   </g> | ||||
|       </tr> | ||||
|     </table> | ||||
|   </div> | ||||
| </template> | ||||
|  |  | |||
|  | @ -0,0 +1,29 @@ | |||
| <script> | ||||
| import { formatRelevantDigits } from '~/lib/utils/number_utils'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'TrackInfo', | ||||
|   props: { | ||||
|     track: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     summaryMetrics() { | ||||
|       return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits( | ||||
|         this.track.max, | ||||
|       )}`; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <span> | ||||
|     <slot> | ||||
|       <strong> {{ track.metricTag }} </strong> | ||||
|     </slot> | ||||
|     {{ summaryMetrics }} | ||||
|   </span> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -0,0 +1,36 @@ | |||
| <script> | ||||
| export default { | ||||
|   name: 'TrackLine', | ||||
|   props: { | ||||
|     track: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     stylizedLine() { | ||||
|       if (this.track.lineStyle === 'dashed') return '6, 3'; | ||||
|       if (this.track.lineStyle === 'dotted') return '3, 3'; | ||||
|       return null; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <td> | ||||
|     <svg | ||||
|       width="15" | ||||
|       height="6"> | ||||
|       <line | ||||
|         :stroke-dasharray="stylizedLine" | ||||
|         :stroke="track.lineColor" | ||||
|         stroke-width="4" | ||||
|         :x1="0" | ||||
|         :x2="15" | ||||
|         :y1="2" | ||||
|         :y2="2" | ||||
|       /> | ||||
|     </svg> | ||||
|   </td> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -1,7 +1,7 @@ | |||
| import _ from 'underscore'; | ||||
| 
 | ||||
| function sortMetrics(metrics) { | ||||
|   return _.chain(metrics).sortBy('weight').sortBy('title').value(); | ||||
|   return _.chain(metrics).sortBy('title').sortBy('weight').value(); | ||||
| } | ||||
| 
 | ||||
| function normalizeMetrics(metrics) { | ||||
|  |  | |||
|  | @ -1,10 +1,21 @@ | |||
| import _ from 'underscore'; | ||||
| import { scaleLinear, scaleTime } from 'd3-scale'; | ||||
| import { line, area, curveLinear } from 'd3-shape'; | ||||
| import { extent, max } from 'd3-array'; | ||||
| import { extent, max, sum } from 'd3-array'; | ||||
| import { timeMinute } from 'd3-time'; | ||||
| import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; | ||||
| 
 | ||||
| const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute }; | ||||
| const d3 = { | ||||
|   scaleLinear, | ||||
|   scaleTime, | ||||
|   line, | ||||
|   area, | ||||
|   curveLinear, | ||||
|   extent, | ||||
|   max, | ||||
|   timeMinute, | ||||
|   sum, | ||||
| }; | ||||
| 
 | ||||
| const defaultColorPalette = { | ||||
|   blue: ['#1f78d1', '#8fbce8'], | ||||
|  | @ -20,6 +31,8 @@ const defaultStyleOrder = ['solid', 'dashed', 'dotted']; | |||
| 
 | ||||
| function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) { | ||||
|   let usedColors = []; | ||||
|   let renderCanary = false; | ||||
|   const timeSeriesParsed = []; | ||||
| 
 | ||||
|   function pickColor(name) { | ||||
|     let pick; | ||||
|  | @ -38,16 +51,23 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom | |||
|     return defaultColorPalette[pick]; | ||||
|   } | ||||
| 
 | ||||
|   return query.result.map((timeSeries, timeSeriesNumber) => { | ||||
|   query.result.forEach((timeSeries, timeSeriesNumber) => { | ||||
|     let metricTag = ''; | ||||
|     let lineColor = ''; | ||||
|     let areaColor = ''; | ||||
|     let shouldRenderLegend = true; | ||||
|     const timeSeriesValues = timeSeries.values.map(d => d.value); | ||||
|     const maximumValue = d3.max(timeSeriesValues); | ||||
|     const accum = d3.sum(timeSeriesValues); | ||||
|     const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable'); | ||||
| 
 | ||||
|     const timeSeriesScaleX = d3.scaleTime() | ||||
|       .range([0, graphWidth - 70]); | ||||
|     if (trackName === 'Canary') { | ||||
|       renderCanary = true; | ||||
|     } | ||||
| 
 | ||||
|     const timeSeriesScaleY = d3.scaleLinear() | ||||
|       .range([graphHeight - graphHeightOffset, 0]); | ||||
|     const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]); | ||||
| 
 | ||||
|     const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]); | ||||
| 
 | ||||
|     timeSeriesScaleX.domain(xDom); | ||||
|     timeSeriesScaleX.ticks(d3.timeMinute, 60); | ||||
|  | @ -55,13 +75,15 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom | |||
| 
 | ||||
|     const defined = d => !isNaN(d.value) && d.value != null; | ||||
| 
 | ||||
|     const lineFunction = d3.line() | ||||
|     const lineFunction = d3 | ||||
|       .line() | ||||
|       .defined(defined) | ||||
|       .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
 | ||||
|       .x(d => timeSeriesScaleX(d.time)) | ||||
|       .y(d => timeSeriesScaleY(d.value)); | ||||
| 
 | ||||
|     const areaFunction = d3.area() | ||||
|     const areaFunction = d3 | ||||
|       .area() | ||||
|       .defined(defined) | ||||
|       .curve(d3.curveLinear) | ||||
|       .x(d => timeSeriesScaleX(d.time)) | ||||
|  | @ -69,38 +91,62 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom | |||
|       .y1(d => timeSeriesScaleY(d.value)); | ||||
| 
 | ||||
|     const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; | ||||
|     const seriesCustomizationData = query.series != null && | ||||
|       _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); | ||||
|     const seriesCustomizationData = | ||||
|       query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); | ||||
| 
 | ||||
|     if (seriesCustomizationData) { | ||||
|       metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; | ||||
|       [lineColor, areaColor] = pickColor(seriesCustomizationData.color); | ||||
|       shouldRenderLegend = false; | ||||
|     } else { | ||||
|       metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; | ||||
|       [lineColor, areaColor] = pickColor(); | ||||
|       if (timeSeriesParsed.length > 1) { | ||||
|         shouldRenderLegend = false; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (query.track) { | ||||
|       metricTag += ` - ${query.track}`; | ||||
|     if (!shouldRenderLegend) { | ||||
|       if (!timeSeriesParsed[0].tracksLegend) { | ||||
|         timeSeriesParsed[0].tracksLegend = []; | ||||
|       } | ||||
|       timeSeriesParsed[0].tracksLegend.push({ | ||||
|         max: maximumValue, | ||||
|         average: accum / timeSeries.values.length, | ||||
|         lineStyle, | ||||
|         lineColor, | ||||
|         metricTag, | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|     timeSeriesParsed.push({ | ||||
|       linePath: lineFunction(timeSeries.values), | ||||
|       areaPath: areaFunction(timeSeries.values), | ||||
|       timeSeriesScaleX, | ||||
|       values: timeSeries.values, | ||||
|       max: maximumValue, | ||||
|       average: accum / timeSeries.values.length, | ||||
|       lineStyle, | ||||
|       lineColor, | ||||
|       areaColor, | ||||
|       metricTag, | ||||
|     }; | ||||
|       trackName, | ||||
|       shouldRenderLegend, | ||||
|       renderCanary, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   return timeSeriesParsed; | ||||
| } | ||||
| 
 | ||||
| export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { | ||||
|   const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat( | ||||
|   const allValues = queries.reduce( | ||||
|     (allQueryResults, query) => | ||||
|       allQueryResults.concat( | ||||
|         query.result.reduce((allResults, result) => allResults.concat(result.values), []), | ||||
|   ), []); | ||||
|       ), | ||||
|     [], | ||||
|   ); | ||||
| 
 | ||||
|   const xDom = d3.extent(allValues, d => d.time); | ||||
|   const yDom = [0, d3.max(allValues.map(d => d.value))]; | ||||
|  |  | |||
|  | @ -13,8 +13,11 @@ export default function initMrNotes() { | |||
|     data() { | ||||
|       const notesDataset = document.getElementById('js-vue-mr-discussions') | ||||
|         .dataset; | ||||
|       const noteableData = JSON.parse(notesDataset.noteableData); | ||||
|       noteableData.noteableType = notesDataset.noteableType; | ||||
| 
 | ||||
|       return { | ||||
|         noteableData: JSON.parse(notesDataset.noteableData), | ||||
|         noteableData, | ||||
|         currentUserData: JSON.parse(notesDataset.currentUserData), | ||||
|         notesData: JSON.parse(notesDataset.notesData), | ||||
|       }; | ||||
|  |  | |||
|  | @ -99,6 +99,10 @@ export default { | |||
|         'js-note-target-reopen': !this.isOpen, | ||||
|       }; | ||||
|     }, | ||||
|     supportQuickActions() { | ||||
|       // Disable quick actions support for Epics | ||||
|       return this.noteableType !== constants.EPIC_NOTEABLE_TYPE; | ||||
|     }, | ||||
|     markdownDocsPath() { | ||||
|       return this.getNotesData.markdownDocsPath; | ||||
|     }, | ||||
|  | @ -313,10 +317,10 @@ Please check your network connection and try again.`; | |||
|     <note-signed-out-widget v-if="!isLoggedIn" /> | ||||
|     <discussion-locked-widget | ||||
|       issuable-type="issue" | ||||
|       v-else-if="!canCreateNote" | ||||
|       v-else-if="isLocked(getNoteableData) && !canCreateNote" | ||||
|     /> | ||||
|     <ul | ||||
|       v-else | ||||
|       v-else-if="canCreateNote" | ||||
|       class="notes notes-form timeline"> | ||||
|       <li class="timeline-entry"> | ||||
|         <div class="timeline-entry-inner"> | ||||
|  | @ -355,7 +359,7 @@ Please check your network connection and try again.`; | |||
|                   name="note[note]" | ||||
|                   class="note-textarea js-vue-comment-form | ||||
| js-gfm-input js-autosize markdown-area js-vue-textarea" | ||||
|                   data-supports-quick-actions="true" | ||||
|                   :data-supports-quick-actions="supportQuickActions" | ||||
|                   aria-label="Description" | ||||
|                   v-model="note" | ||||
|                   ref="textarea" | ||||
|  |  | |||
|  | @ -40,6 +40,10 @@ export default { | |||
|       type: Boolean, | ||||
|       required: true, | ||||
|     }, | ||||
|     canAwardEmoji: { | ||||
|       type: Boolean, | ||||
|       required: true, | ||||
|     }, | ||||
|     canDelete: { | ||||
|       type: Boolean, | ||||
|       required: true, | ||||
|  | @ -74,9 +78,6 @@ export default { | |||
|     shouldShowActionsDropdown() { | ||||
|       return this.currentUserId && (this.canEdit || this.canReportAsAbuse); | ||||
|     }, | ||||
|     canAddAwardEmoji() { | ||||
|       return this.currentUserId; | ||||
|     }, | ||||
|     isAuthoredByCurrentUser() { | ||||
|       return this.authorId === this.currentUserId; | ||||
|     }, | ||||
|  | @ -149,7 +150,7 @@ export default { | |||
|       </button> | ||||
|     </div> | ||||
|     <div | ||||
|       v-if="canAddAwardEmoji" | ||||
|       v-if="canAwardEmoji" | ||||
|       class="note-actions-item"> | ||||
|       <a | ||||
|         v-tooltip | ||||
|  |  | |||
|  | @ -28,6 +28,10 @@ export default { | |||
|       type: Number, | ||||
|       required: true, | ||||
|     }, | ||||
|     canAwardEmoji: { | ||||
|       type: Boolean, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters(['getUserData']), | ||||
|  | @ -67,9 +71,6 @@ export default { | |||
|     isAuthoredByMe() { | ||||
|       return this.noteAuthorId === this.getUserData.id; | ||||
|     }, | ||||
|     isLoggedIn() { | ||||
|       return this.getUserData.id; | ||||
|     }, | ||||
|   }, | ||||
|   created() { | ||||
|     this.emojiSmiling = emojiSmiling; | ||||
|  | @ -156,7 +157,7 @@ export default { | |||
|       return title; | ||||
|     }, | ||||
|     handleAward(awardName) { | ||||
|       if (!this.isLoggedIn) { | ||||
|       if (!this.canAwardEmoji) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|  | @ -208,7 +209,7 @@ export default { | |||
|         </span> | ||||
|       </button> | ||||
|       <div | ||||
|         v-if="isLoggedIn" | ||||
|         v-if="canAwardEmoji" | ||||
|         class="award-menu-holder"> | ||||
|         <button | ||||
|           v-tooltip | ||||
|  |  | |||
|  | @ -112,6 +112,7 @@ export default { | |||
|       :note-author-id="note.author.id" | ||||
|       :awards="note.award_emoji" | ||||
|       :toggle-award-path="note.toggle_award_path" | ||||
|       :can-award-emoji="note.current_user.can_award_emoji" | ||||
|     /> | ||||
|     <note-attachment | ||||
|       v-if="note.attachment" | ||||
|  |  | |||
|  | @ -177,6 +177,7 @@ export default { | |||
|             :note-id="note.id" | ||||
|             :access-level="note.human_access" | ||||
|             :can-edit="note.current_user.can_edit" | ||||
|             :can-award-emoji="note.current_user.can_award_emoji" | ||||
|             :can-delete="note.current_user.can_edit" | ||||
|             :can-report-as-abuse="canReportAsAbuse" | ||||
|             :report-abuse-path="note.report_abuse_path" | ||||
|  |  | |||
|  | @ -49,12 +49,7 @@ export default { | |||
|   computed: { | ||||
|     ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), | ||||
|     noteableType() { | ||||
|       // FIXME -- @fatihacet Get this from JSON data. | ||||
|       const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; | ||||
| 
 | ||||
|       return this.noteableData.merge_params | ||||
|         ? MERGE_REQUEST_NOTEABLE_TYPE | ||||
|         : ISSUE_NOTEABLE_TYPE; | ||||
|       return this.noteableData.noteableType; | ||||
|     }, | ||||
|     allNotes() { | ||||
|       if (this.isLoading) { | ||||
|  |  | |||
|  | @ -10,6 +10,13 @@ export const CLOSED = 'closed'; | |||
| export const EMOJI_THUMBSUP = 'thumbsup'; | ||||
| export const EMOJI_THUMBSDOWN = 'thumbsdown'; | ||||
| export const ISSUE_NOTEABLE_TYPE = 'issue'; | ||||
| export const EPIC_NOTEABLE_TYPE = 'epic'; | ||||
| export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; | ||||
| export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; | ||||
| export const RESOLVE_NOTE_METHOD_NAME = 'post'; | ||||
| 
 | ||||
| export const NOTEABLE_TYPE_MAPPING = { | ||||
|   Issue: ISSUE_NOTEABLE_TYPE, | ||||
|   MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, | ||||
|   Epic: EPIC_NOTEABLE_TYPE, | ||||
| }; | ||||
|  |  | |||
|  | @ -12,8 +12,11 @@ document.addEventListener( | |||
|       data() { | ||||
|         const notesDataset = document.getElementById('js-vue-notes').dataset; | ||||
|         const parsedUserData = JSON.parse(notesDataset.currentUserData); | ||||
|         const noteableData = JSON.parse(notesDataset.noteableData); | ||||
|         let currentUserData = {}; | ||||
| 
 | ||||
|         noteableData.noteableType = notesDataset.noteableType; | ||||
| 
 | ||||
|         if (parsedUserData) { | ||||
|           currentUserData = { | ||||
|             id: parsedUserData.id, | ||||
|  | @ -25,7 +28,7 @@ document.addEventListener( | |||
|         } | ||||
| 
 | ||||
|         return { | ||||
|           noteableData: JSON.parse(notesDataset.noteableData), | ||||
|           noteableData, | ||||
|           currentUserData, | ||||
|           notesData: JSON.parse(notesDataset.notesData), | ||||
|         }; | ||||
|  |  | |||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue