diff options
author | Kamil Trzciński <ayufan@ayufan.eu> | 2017-05-06 17:09:47 +0000 |
---|---|---|
committer | Kamil Trzciński <ayufan@ayufan.eu> | 2017-05-06 17:09:47 +0000 |
commit | b1ad5c186066f11c21ef165957f6e6c8350b4275 (patch) | |
tree | 5e11a94e60e6d1c28590e0cbed3ffc49799e709f | |
parent | 739e797575d47ec796206865c4d82917cb2ad93d (diff) | |
parent | b7c480d7c010ec9dd8e0aa470f8a8010f1f4e6ba (diff) | |
download | gitlab-ce-add-index-for-auto_canceled_by_id-mysql.tar.gz |
Merge branch 'master' into 'add-index-for-auto_canceled_by_id-mysql'add-index-for-auto_canceled_by_id-mysql
# Conflicts:
# db/schema.rb
961 files changed, 26039 insertions, 5060 deletions
diff --git a/.eslintignore b/.eslintignore index c742b08c005..1605e483e9e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,3 +7,4 @@ /vendor/ karma.config.js webpack.config.js +/app/assets/javascripts/locale/**/*.js diff --git a/.gitignore b/.gitignore index bb818213de1..0fb97ffb98e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.log *.swp +*.mo +*.edit.po .DS_Store .bundle .chef @@ -54,3 +56,4 @@ eslint-report.html /shared/* /.gitlab_workhorse_secret /webpack-report/ +/locale/**/LC_MESSAGES diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 75e419b4223..588f255eff8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,6 +23,7 @@ before_script: - source scripts/prepare_build.sh stages: +- build - prepare - test - post-test @@ -137,6 +138,28 @@ stages: <<: *only-master-and-ee-or-mysql <<: *except-docs +# Trigger a package build on omnibus-gitlab repository + +build-package: + services: [] + variables: + SETUP_DB: "false" + USE_BUNDLE_INSTALL: "false" + stage: build + when: manual + script: + # If no branch in omnibus is specified, trigger pipeline against master + - if [ -z "$OMNIBUS_BRANCH" ] ; then export OMNIBUS_BRANCH=master ;fi + - echo "token=${BUILD_TRIGGER_TOKEN}" > version_details + - echo "ref=${OMNIBUS_BRANCH}" >> version_details + - echo "variables[ALTERNATIVE_SOURCES]=true" >> version_details + - echo "variables[GITLAB_VERSION]=${CI_COMMIT_SHA}" >> version_details + # Collect version details of all components + - for f in *_VERSION; do echo "variables[$f]=$(cat $f)" >> version_details; done + # Trigger the API and pass values collected above as parameters to it + - cat version_details | tr '\n' '&' | curl -X POST https://gitlab.com/api/v4/projects/20699/trigger/pipeline --data-binary @- + - rm version_details + # Prepare and merge knapsack tests knapsack: <<: *knapsack-state @@ -269,6 +292,25 @@ static-analysis: script: - scripts/static-analysis +# Documentation checks: +# - Check validity of relative links +# - Make sure cURL examples in API docs use the full switches +docs lint: + image: "registry.gitlab.com/gitlab-org/gitlab-build-images:nanoc-bootstrap-ruby-2.4-alpine" + stage: test + <<: *dedicated-runner + cache: {} + dependencies: [] + before_script: [] + script: + - scripts/lint-doc.sh + - mv doc/ /nanoc/content/ + - cd /nanoc + # Build HTML from Markdown + - bundle exec nanoc + # Check the internal links + - bundle exec nanoc check internal_links + downtime_check: <<: *rake-exec except: @@ -300,22 +342,22 @@ ee_compat_check: .db-migrate-reset: &db-migrate-reset stage: test <<: *dedicated-runner + <<: *except-docs script: - bundle exec rake db:migrate:reset rake pg db:migrate:reset: <<: *db-migrate-reset <<: *use-pg - <<: *except-docs rake mysql db:migrate:reset: <<: *db-migrate-reset <<: *use-mysql - <<: *except-docs .db-rollback: &db-rollback stage: test <<: *dedicated-runner + <<: *except-docs script: - bundle exec rake db:rollback STEP=120 - bundle exec rake db:migrate @@ -323,16 +365,15 @@ rake mysql db:migrate:reset: rake pg db:rollback: <<: *db-rollback <<: *use-pg - <<: *except-docs rake mysql db:rollback: <<: *db-rollback <<: *use-mysql - <<: *except-docs .db-seed_fu: &db-seed_fu stage: test <<: *dedicated-runner + <<: *except-docs variables: SIZE: "1" SETUP_DB: "false" @@ -350,12 +391,10 @@ rake mysql db:rollback: rake pg db:seed_fu: <<: *db-seed_fu <<: *use-pg - <<: *except-docs rake mysql db:seed_fu: <<: *db-seed_fu <<: *use-mysql - <<: *except-docs rake gitlab:assets:compile: stage: test @@ -396,33 +435,6 @@ rake karma: paths: - coverage-javascript/ -docs:check:links: - image: "registry.gitlab.com/gitlab-org/gitlab-build-images:nanoc-bootstrap-ruby-2.4-alpine" - stage: test - <<: *dedicated-runner - cache: {} - dependencies: [] - before_script: [] - script: - - mv doc/ /nanoc/content/ - - cd /nanoc - # Build HTML from Markdown - - bundle exec nanoc - # Check the internal links - - bundle exec nanoc check internal_links - -bundler:audit: - stage: test - <<: *ruby-static-analysis - <<: *dedicated-runner - only: - - master@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - - master@gitlab/gitlabhq - - master@gitlab/gitlab-ee - script: - - "bundle exec bundle-audit check --update --ignore CVE-2016-4658" - .migration-paths: &migration-paths stage: test <<: *dedicated-runner diff --git a/.rubocop.yml b/.rubocop.yml index 8c43f6909cf..e53af97a92c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -961,7 +961,7 @@ RSpec/DescribeSymbol: # Checks that the second argument to top level describe is the tested method # name. RSpec/DescribedClass: - Enabled: false + Enabled: true # Checks for long example. RSpec/ExampleLength: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2686d778b09..e625278a796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 9.1.3 (2017-05-05) + +- Do not show private groups on subgroups page if user doesn't have access to. +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. + ## 9.1.2 (2017-05-01) - Add index on ci_runners.contacted_at. !10876 (blackst0ne) @@ -276,6 +288,18 @@ entry. - Only send chat notifications for the default branch. - Don't fill in the default kubernetes namespace. +## 9.0.7 (2017-05-05) + +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. +- Do not show private groups on subgroups page if user doesn't have access to. + ## 9.0.6 (2017-04-21) - Bugfix: POST /projects/:id/hooks and PUT /projects/:id/hook/:hook_id no longer ignore the the job_events param in the V4 API. !10586 @@ -620,6 +644,17 @@ entry. - Change development tanuki favicon colors to match logo color order. - API issues - support filtering by iids. +## 8.17.6 (2017-05-05) + +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. + ## 8.17.5 (2017-04-05) - Don’t show source project name when user does not have access. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73c8a77364b..8b6c87ae518 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,27 +13,29 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._ <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* -- [Contributor license agreement](#contributor-license-agreement) - [Contribute to GitLab](#contribute-to-gitlab) - [Security vulnerability disclosure](#security-vulnerability-disclosure) - [Closing policy for issues and merge requests](#closing-policy-for-issues-and-merge-requests) - [Helping others](#helping-others) - [I want to contribute!](#i-want-to-contribute) -- [Implement design & UI elements](#implement-design-ui-elements) -- [Release retrospective and kickoff](#release-retrospective-and-kickoff) - - [Retrospective](#retrospective) - - [Kickoff](#kickoff) +- [Workflow labels](#workflow-labels) + - [Type labels (~"feature proposal", ~bug, ~customer, etc.)](#type-labels-feature-proposal-bug-customer-etc) + - [Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)](#subject-labels-wiki-container-registry-ldap-api-etc) + - [Team labels (~CI, ~Discussion, ~Edge, ~Platform, etc.)](#team-labels-ci-discussion-edge-platform-etc) + - [Priority labels (~Deliverable and ~Stretch)](#priority-labels-deliverable-and-stretch) + - [Label for community contributors (~"Accepting Merge Requests")](#label-for-community-contributors-accepting-merge-requests) +- [Implement design & UI elements](#implement-design--ui-elements) - [Issue tracker](#issue-tracker) - - [Feature proposals](#feature-proposals) - - [Issue tracker guidelines](#issue-tracker-guidelines) - - [Issue weight](#issue-weight) - - [Regression issues](#regression-issues) - - [Technical debt](#technical-debt) - - [Stewardship](#stewardship) + - [Issue triaging](#issue-triaging) + - [Feature proposals](#feature-proposals) + - [Issue tracker guidelines](#issue-tracker-guidelines) + - [Issue weight](#issue-weight) + - [Regression issues](#regression-issues) + - [Technical debt](#technical-debt) + - [Stewardship](#stewardship) - [Merge requests](#merge-requests) - - [Merge request guidelines](#merge-request-guidelines) - - [Contribution acceptance criteria](#contribution-acceptance-criteria) -- [Changes for Stable Releases](#changes-for-stable-releases) + - [Merge request guidelines](#merge-request-guidelines) + - [Contribution acceptance criteria](#contribution-acceptance-criteria) - [Definition of done](#definition-of-done) - [Style guides](#style-guides) - [Code of conduct](#code-of-conduct) @@ -103,34 +105,128 @@ contributing to GitLab. ## Workflow labels -Labelling issues is described in the [GitLab Inc engineering workflow]. +To allow for asynchronous issue handling, we use [milestones][milestones-page] +and [labels][labels-page]. Leads and product managers handle most of the +scheduling into milestones. Labelling is a task for everyone. -## Implement design & UI elements +Most issues will have labels for at least one of the following: -Please see the [UX Guide for GitLab]. +- Type: ~"feature proposal", ~bug, ~customer, etc. +- Subject: ~wiki, ~"container registry", ~ldap, ~api, etc. +- Team: ~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc. +- Priority: ~Deliverable, ~Stretch + +All labels, their meaning and priority are defined on the +[labels page][labels-page]. + +If you come across an issue that has none of these, and you're allowed to set +labels, you can _always_ add the team and type, and often also the subject. + +[milestones-page]: https://gitlab.com/gitlab-org/gitlab-ce/milestones +[labels-page]: https://gitlab.com/gitlab-org/gitlab-ce/labels + +### Type labels (~"feature proposal", ~bug, ~customer, etc.) + +Type labels are very important. They define what kind of issue this is. Every +issue should have one or more. + +Examples of type labels are ~"feature proposal", ~bug, ~customer, ~security, +and ~"direction". + +A number of type labels have a priority assigned to them, which automatically +makes them float to the top, depending on their importance. + +Type labels are always lowercase, and can have any color, besides blue (which is +already reserved for subject labels). + +The descriptions on the [labels page][labels-page] explain what falls under each type label. + +### Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.) + +Subject labels are labels that define what area or feature of GitLab this issue +hits. They are not always necessary, but very convenient. + +If you are an expert in a particular area, it makes it easier to find issues to +work on. You can also subscribe to those labels to receive an email each time an +issue is labelled with a subject label corresponding to your expertise. + +Examples of subject labels are ~wiki, ~"container registry", ~ldap, ~api, +~issues, ~"merge requests", ~labels, and ~"container registry". + +Subject labels are always all-lowercase. + +### Team labels (~CI, ~Discussion, ~Edge, ~Platform, etc.) + +Team labels specify what team is responsible for this issue. +Assigning a team label makes sure issues get the attention of the appropriate +people. + +The current team labels are ~Build, ~CI, ~Discussion, ~Documentation, ~Edge, +~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX". + +The descriptions on the [labels page][labels-page] explain what falls under the +responsibility of each team. + +Within those team labels, we also have the ~backend and ~frontend labels to +indicate if an issue needs backend work, frontend work, or both. + +Team labels are always capitalized so that they show up as the first label for +any issue. -## Release retrospective and kickoff +### Priority labels (~Deliverable and ~Stretch) -### Retrospective +Priority labels help us clearly communicate expectations of the work for the +release. There are two levels of priority labels: -After each release, we have a retrospective call where we discuss what went well, -what went wrong, and what we can improve for the next release. The -[retrospective notes] are public and you are invited to comment on them. -If you're interested, you can even join the -[retrospective call][retro-kickoff-call], on the first working day after the -22nd at 6pm CET / 9am PST. +- ~Deliverable: Issues that are expected to be delivered in the current + milestone. +- ~Stretch: Issues that are a stretch goal for delivering in the current + milestone. If these issues are not done in the current release, they will + strongly be considered for the next release. -### Kickoff +### Label for community contributors (~"Accepting Merge Requests") -Before working on the next release, we have a -kickoff call to explain what we expect to ship in the next release. The -[kickoff notes] are public and you are invited to comment on them. -If you're interested, you can even join the [kickoff call][retro-kickoff-call], -on the first working day after the 7th at 6pm CET / 9am PST.. +Issues that are beneficial to our users, 'nice to haves', that we currently do +not have the capacity for or want to give the priority to, are labeled as +~"Accepting Merge Requests", so the community can make a contribution. -[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing -[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing -[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206 +Community contributors can submit merge requests for any issue they want, but +the ~"Accepting Merge Requests" label has a special meaning. It points to +changes that: + +1. We already agreed on, +1. Are well-defined, +1. Are likely to get accepted by a maintainer. + +We want to avoid a situation when a contributor picks an +~"Accepting Merge Requests" issue and then their merge request gets closed, +because we realize that it does not fit our vision, or we want to solve it in a +different way. + +We add the ~"Accepting Merge Requests" label to: + +- Low priority ~bug issues (i.e. we do not add it to the bugs that we want to +solve in the ~"Next Patch Release") +- Small ~"feature proposal" that do not need ~UX / ~"Product work", or for which +the ~UX / ~"Product work" is already done +- Small ~"technical debt" issues + +After adding the ~"Accepting Merge Requests" label, we try to estimate the +[weight](#issue-weight) of the issue. We use issue weight to let contributors +know how difficult the issue is. Additionally: + +- We advertise [~"Accepting Merge Requests" issues with weight < 5][up-for-grabs] + as suitable for people that have never contributed to GitLab before on the + [Up For Grabs campaign](http://up-for-grabs.net) +- We encourage people that have never contributed to any open source project to + look for [~"Accepting Merge Requests" issues with a weight of 1][firt-timers] + +[up-for-grabs]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name=Accepting+Merge+Requests&scope=all&sort=weight_asc&state=opened +[firt-timers]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=Accepting+Merge+Requests&scope=all&sort=upvotes_desc&state=opened&weight=1 + +## Implement design & UI elements + +Please see the [UX Guide for GitLab]. ## Issue tracker @@ -154,6 +250,21 @@ If it happens that you know the solution to an existing bug, please first open the issue in order to keep track of it and then open the relevant merge request that potentially fixes it. +### Issue triaging + +Our issue triage policies are [described in our handbook]. You are very welcome +to help the GitLab team triage issues. We also organize [issue bash events] once +every quarter. + +The most important thing is making sure valid issues receive feedback from the +development team. Therefore the priority is mentioning developers that can help +on those issues. Please select someone with relevant experience from the +[GitLab team][team]. If there is nobody mentioned with that expertise look in +the commit history for the affected files to find someone. + +[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/ +[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 + ### Feature proposals To create a feature proposal for CE, open an issue on the @@ -327,13 +438,17 @@ request is as follows: "Description" field. 1. If you are contributing documentation, choose `Documentation` from the "Choose a template" menu and fill in the template. + 1. Mention the issue(s) your merge request solves, using the `Solves #XXX` or + `Closes #XXX` syntax to auto-close the issue(s) once the merge request will + be merged. +1. If you're allowed to, set a relevant milestone and labels 1. If the MR changes the UI it should include *Before* and *After* screenshots 1. If the MR changes CSS classes please include the list of affected pages, `grep css-class ./app -R` -1. Link any relevant [issues][ce-tracker] in the merge request description and - leave a comment on them with a link back to the MR 1. Be prepared to answer questions and incorporate feedback even if requests for this arrive weeks or months after your MR submission + 1. If a discussion has been addressed, select the "Resolve discussion" button + beneath it to mark it resolved. 1. If your MR touches code that executes shell commands, reads or opens files or handles paths to files on disk, make sure it adheres to the [shell command guidelines](doc/development/shell_commands.md) @@ -369,24 +484,6 @@ Please ensure that your merge request meets the contribution acceptance criteria When having your code reviewed and when reviewing merge requests please take the [code review guidelines](doc/development/code_review.md) into account. -### Getting your merge request reviewed, approved, and merged - -There are a few rules to get your merge request accepted: - -1. Your merge request should only be **merged by a [maintainer][team]**. - 1. If your merge request includes only backend changes [^1], it must be - **approved by a [backend maintainer][team]**. - 1. If your merge request includes only frontend changes [^1], it must be - **approved by a [frontend maintainer][team]**. - 1. If your merge request includes frontend and backend changes [^1], it must - be **approved by a [frontend and a backend maintainer][team]**. -1. To lower the amount of merge requests maintainers need to review, you can - ask or assign any [reviewers][team] for a first review. - 1. If you need some guidance (e.g. it's your first merge request), feel free - to ask one of the [Merge request coaches][team]. - 1. The reviewer will assign the merge request to a maintainer once the - reviewer is satisfied with the state of the merge request. - ### Contribution acceptance criteria 1. The change is as small as possible @@ -416,8 +513,7 @@ There are a few rules to get your merge request accepted: 1. If you need polling to support real-time features, please use [polling with ETag caching][polling-etag]. 1. Changes after submitting the merge request should be in separate commits - (no squashing). If necessary, you will be asked to squash when the review is - over, before merging. + (no squashing). 1. It conforms to the [style guides](#style-guides) and the following: - If your change touches a line that does not follow the style, modify the entire line to follow it. This prevents linting tools from generating warnings. @@ -428,19 +524,6 @@ There are a few rules to get your merge request accepted: See the instructions in that document for help if your MR fails the "license-finder" test with a "Dependencies that need approval" error. -## Changes for Stable Releases - -Sometimes certain changes have to be added to an existing stable release. -Two examples are bug fixes and performance improvements. In these cases the -corresponding merge request should be updated to have the following: - -1. A milestone indicating what release the merge request should be merged into. -1. The label "Pick into Stable" - -This makes it easier for release managers to keep track of what still has to be -merged and where changes have to be merged into. -Like all merge requests the target should be master so all bugfixes are in master. - ## Definition of done If you contribute to GitLab please know that changes involve more than just @@ -449,16 +532,16 @@ the feature you contribute through all of these steps. 1. Description explaining the relevancy (see following item) 1. Working and clean code that is commented where needed -1. Unit and integration tests that pass on the CI server +1. [Unit and system tests][testing] that pass on the CI server 1. Performance/scalability implications have been considered, addressed, and tested -1. [Documented][doc-styleguide] in the /doc directory -1. Changelog entry added +1. [Documented][doc-styleguide] in the `/doc` directory +1. [Changelog entry added][changelog], if necessary 1. Reviewed and any concerns are addressed -1. Merged by the project lead -1. Added to the release blog article -1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/) if relevant +1. Merged by a project maintainer +1. Added to the release blog article, if relevant +1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/), if relevant 1. Community questions answered -1. Answers to questions radiated (in docs/wiki/etc.) +1. Answers to questions radiated (in docs/wiki/support etc.) If you add a dependency in GitLab (such as an operating system package) please consider updating the following and note the applicability of each in your @@ -481,7 +564,7 @@ merge request: - string literal quoting style **Option A**: single quoted by default 1. [Rails](https://github.com/bbatsov/rails-style-guide) 1. [Newlines styleguide][newlines-styleguide] -1. [Testing](doc/development/testing.md) +1. [Testing][testing] 1. [JavaScript styleguide][js-styleguide] 1. [SCSS styleguide][scss-styleguide] 1. [Shell commands](doc/development/shell_commands.md) created by GitLab @@ -558,6 +641,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [license-finder-doc]: doc/development/licensing.md [GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues [polling-etag]: https://docs.gitlab.com/ce/development/polling.html +[testing]: doc/development/testing.md [^1]: Please note that specs other than JavaScript specs are considered backend code. diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index 267577d47e4..2b7c5ae0184 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.4.1 +0.4.2 diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index 428b770e3e2..227cea21564 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -1.4.3 +2.0.0 @@ -256,6 +256,12 @@ gem 'sentry-raven', '~> 2.4.0' gem 'premailer-rails', '~> 1.9.0' +# I18n +gem 'ruby_parser', '~> 3.8.4', require: false +gem 'gettext_i18n_rails', '~> 1.8.0' +gem 'gettext_i18n_rails_js', '~> 1.2.0' +gem 'gettext', '~> 3.2.2', require: false, group: :development + # Metrics group :metrics do gem 'allocations', '~> 1.0', require: false, platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index b822a325861..01c35a935f2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -198,6 +198,7 @@ GEM faraday_middleware-multi_json (0.0.6) faraday_middleware multi_json + fast_gettext (1.4.0) ffaker (2.4.0) ffi (1.9.10) flay (2.8.1) @@ -251,6 +252,16 @@ GEM gemojione (3.0.1) json get_process_mem (0.2.0) + gettext (3.2.2) + locale (>= 2.0.5) + text (>= 1.3.0) + gettext_i18n_rails (1.8.0) + fast_gettext (>= 0.9.0) + gettext_i18n_rails_js (1.2.0) + gettext (>= 3.0.2) + gettext_i18n_rails (>= 0.7.1) + po_to_json (>= 1.0.0) + rails (>= 3.2.0) gherkin-ruby (0.3.2) gitaly (0.5.0) google-protobuf (~> 3.1) @@ -422,6 +433,7 @@ GEM licensee (8.7.0) rugged (~> 0.24) little-plugger (1.1.4) + locale (2.1.2) logging (2.1.0) little-plugger (~> 1.1) multi_json (~> 1.10) @@ -525,6 +537,8 @@ GEM ast (~> 2.2) path_expander (1.0.1) pg (0.18.4) + po_to_json (1.0.1) + json (>= 1.6.0) poltergeist (1.9.0) capybara (~> 2.1) cliver (~> 0.3.1) @@ -777,6 +791,7 @@ GEM temple (0.7.7) test_after_commit (1.1.0) activerecord (>= 3.2) + text (1.3.1) thin (1.7.0) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) @@ -904,6 +919,9 @@ DEPENDENCIES fuubar (~> 2.0.0) gemnasium-gitlab-service (~> 0.2) gemojione (~> 3.0) + gettext (~> 3.2.2) + gettext_i18n_rails (~> 1.8.0) + gettext_i18n_rails_js (~> 1.2.0) gitaly (~> 0.5.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) @@ -994,6 +1012,7 @@ DEPENDENCIES rubocop-rspec (~> 1.15.0) ruby-fogbugz (~> 0.2.1) ruby-prof (~> 0.16.2) + ruby_parser (~> 3.8.4) rufus-scheduler (~> 3.1.10) rugged (~> 0.25.1.1) sanitize (~> 2.0) diff --git a/PROCESS.md b/PROCESS.md index fac3c22e09f..3b97a4e8c75 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1,35 +1,53 @@ -# GitLab Contributing Process +## GitLab Core Team & GitLab Inc. Contribution Process + +--- + +<!-- START doctoc generated TOC please keep comment here to allow auto update --> +<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [Purpose of describing the contributing process](#purpose-of-describing-the-contributing-process) +- [Common actions](#common-actions) + - [Merge request coaching](#merge-request-coaching) +- [Assigning issues](#assigning-issues) +- [Be kind](#be-kind) +- [Feature freeze on the 7th for the release on the 22nd](#feature-freeze-on-the-7th-for-the-release-on-the-22nd) + - [Between the 1st and the 7th](#between-the-1st-and-the-7th) + - [On the 7th](#on-the-7th) + - [After the 7th](#after-the-7th) +- [Release retrospective and kickoff](#release-retrospective-and-kickoff) + - [Retrospective](#retrospective) + - [Kickoff](#kickoff) +- [Copy & paste responses](#copy--paste-responses) + - [Improperly formatted issue](#improperly-formatted-issue) + - [Issue report for old version](#issue-report-for-old-version) + - [Support requests and configuration questions](#support-requests-and-configuration-questions) + - [Code format](#code-format) + - [Issue fixed in newer version](#issue-fixed-in-newer-version) + - [Improperly formatted merge request](#improperly-formatted-merge-request) + - [Inactivity close of an issue](#inactivity-close-of-an-issue) + - [Inactivity close of a merge request](#inactivity-close-of-a-merge-request) + - [Accepting merge requests](#accepting-merge-requests) + - [Only accepting merge requests with green tests](#only-accepting-merge-requests-with-green-tests) + - [Closing down the issue tracker on GitHub](#closing-down-the-issue-tracker-on-github) + +<!-- END doctoc generated TOC please keep comment here to allow auto update --> + +--- ## Purpose of describing the contributing process -Below we describe the contributing process to GitLab for two reasons. So that -contributors know what to expect from maintainers (possible responses, friendly -treatment, etc.). And so that maintainers know what to expect from contributors -(use the latest version, ensure that the issue is addressed, friendly treatment, -etc.). +Below we describe the contributing process to GitLab for two reasons: + +1. Contributors know what to expect from maintainers (possible responses, friendly + treatment, etc.) +1. Maintainers know what to expect from contributors (use the latest version, + ensure that the issue is addressed, friendly treatment, etc.). - [GitLab Inc engineers should refer to the engineering workflow document](https://about.gitlab.com/handbook/engineering/workflow/) ## Common actions -### Issue triaging - -Our issue triage policies are [described in our handbook]. You are very welcome -to help the GitLab team triage issues. We also organize [issue bash events] once -every quarter. - -The most important thing is making sure valid issues receive feedback from the -development team. Therefore the priority is mentioning developers that can help -on those issues. Please select someone with relevant experience from -[GitLab team][team]. If there is nobody mentioned with that expertise -look in the commit history for the affected files to find someone. Avoid -mentioning the lead developer, this is the person that is least likely to give a -timely response. If the involvement of the lead developer is needed the other -core team members will mention this person. - -[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/ -[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 - ### Merge request coaching Several people from the [GitLab team][team] are helping community members to get @@ -37,12 +55,6 @@ their contributions accepted by meeting our [Definition of done][done]. What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/. -## Workflow labels - -Labelling issues is described in the [GitLab Inc engineering workflow]. - -[GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues - ## Assigning issues If an issue is complex and needs the attention of a specific person, assignment is a good option but assigning issues might discourage other people from contributing to that issue. We need all the contributions we can get so this should never be discouraged. Also, an assigned person might not have time for a few weeks, so others should feel free to takeover. @@ -146,6 +158,29 @@ release should have the correct milestone assigned _and_ have the label Merge requests without a milestone and this label will not be merged into any stable branches. +## Release retrospective and kickoff + +### Retrospective + +After each release, we have a retrospective call where we discuss what went well, +what went wrong, and what we can improve for the next release. The +[retrospective notes] are public and you are invited to comment on them. +If you're interested, you can even join the +[retrospective call][retro-kickoff-call], on the first working day after the +22nd at 6pm CET / 9am PST. + +### Kickoff + +Before working on the next release, we have a +kickoff call to explain what we expect to ship in the next release. The +[kickoff notes] are public and you are invited to comment on them. +If you're interested, you can even join the [kickoff call][retro-kickoff-call], +on the first working day after the 7th at 6pm CET / 9am PST.. + +[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing +[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing +[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206 + ## Copy & paste responses ### Improperly formatted issue diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 8630b18a73f..cfab6c40b34 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,8 +1,11 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */ +import AccessorUtilities from './lib/utils/accessor'; window.Autosave = (function() { function Autosave(field, key) { this.field = field; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + if (key.join != null) { key = key.join("/"); } @@ -17,16 +20,12 @@ window.Autosave = (function() { } Autosave.prototype.restore = function() { - var e, text; - if (window.localStorage == null) { - return; - } - try { - text = window.localStorage.getItem(this.key); - } catch (error) { - e = error; - return; - } + var text; + + if (!this.isLocalStorageAvailable) return; + + text = window.localStorage.getItem(this.key); + if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } @@ -35,27 +34,22 @@ window.Autosave = (function() { Autosave.prototype.save = function() { var text; - if (window.localStorage == null) { - return; - } text = this.field.val(); - if ((text != null ? text.length : void 0) > 0) { - try { - return window.localStorage.setItem(this.key, text); - } catch (error) {} - } else { - return this.reset(); + + if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { + return window.localStorage.setItem(this.key, text); } + + return this.reset(); }; Autosave.prototype.reset = function() { - if (window.localStorage == null) { - return; - } - try { - return window.localStorage.removeItem(this.key); - } catch (error) {} + if (!this.isLocalStorageAvailable) return; + + return window.localStorage.removeItem(this.key); }; return Autosave; })(); + +export default window.Autosave; diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 19a607309e4..23d91fdb259 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -62,6 +62,7 @@ function glEmojiTag(inputName, options) { data-fallback-src="${fallbackImageSrc}" ${fallbackSpriteAttribute} data-unicode-version="${emojiInfo.unicodeVersion}" + title="${emojiInfo.description}" > ${contents} </gl-emoji> diff --git a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js index aa522e20c36..257df55e54f 100644 --- a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js +++ b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js @@ -1,3 +1,5 @@ +import AccessorUtilities from '../../lib/utils/accessor'; + const unicodeSupportTestMap = { // man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/ // occupationZwj: '\u{1F468}\u{200D}\u{1F393}', @@ -140,16 +142,25 @@ function generateUnicodeSupportMap(testMap) { function getUnicodeSupportMap() { let unicodeSupportMap; - const userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent'); + let userAgentFromCache; + + const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + + if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent'); + try { unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map')); } catch (err) { // swallow } + if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) { unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap); - window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); - window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + + if (isLocalStorageAvailable) { + window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); + window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + } } return unicodeSupportMap; diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 3d162b24413..1f9e0448084 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { const $submitButton = $form.find('input[type=submit], button[type=submit]'); if (!$submitButton.attr('disabled')) { + $submitButton.trigger('click', [e]); $submitButton.disable(); - $form.submit(); } }); diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js new file mode 100644 index 00000000000..cdbfe36ca1c --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -0,0 +1,114 @@ +/* global Flash */ + +import sqljs from 'sql.js'; +import { template as _template } from 'underscore'; + +const PREVIEW_TEMPLATE = _template(` + <div class="panel panel-default"> + <div class="panel-heading"><%- name %></div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/> + </div> + </div> +`); + +class BalsamiqViewer { + constructor(viewer) { + this.viewer = viewer; + this.endpoint = this.viewer.dataset.endpoint; + } + + loadFile() { + const xhr = new XMLHttpRequest(); + + xhr.open('GET', this.endpoint, true); + xhr.responseType = 'arraybuffer'; + + xhr.onload = this.renderFile.bind(this); + xhr.onerror = BalsamiqViewer.onError; + + xhr.send(); + } + + renderFile(loadEvent) { + const container = document.createElement('ul'); + + this.initDatabase(loadEvent.target.response); + + const previews = this.getPreviews(); + previews.forEach((preview) => { + const renderedPreview = this.renderPreview(preview); + + container.appendChild(renderedPreview); + }); + + container.classList.add('list-inline'); + container.classList.add('previews'); + + this.viewer.appendChild(container); + } + + initDatabase(data) { + const previewBinary = new Uint8Array(data); + + this.database = new sqljs.Database(previewBinary); + } + + getPreviews() { + const thumbnails = this.database.exec('SELECT * FROM thumbnails'); + + return thumbnails[0].values.map(BalsamiqViewer.parsePreview); + } + + getResource(resourceID) { + const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`); + + return resources[0]; + } + + renderPreview(preview) { + const previewElement = document.createElement('li'); + + previewElement.classList.add('preview'); + previewElement.innerHTML = this.renderTemplate(preview); + + return previewElement; + } + + renderTemplate(preview) { + const resource = this.getResource(preview.resourceID); + const name = BalsamiqViewer.parseTitle(resource); + const image = preview.image; + + const template = PREVIEW_TEMPLATE({ + name, + image, + }); + + return template; + } + + static parsePreview(preview) { + return JSON.parse(preview[1]); + } + + /* + * resource = { + * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'], + * values: [['id', 'branchId', 'attributes', 'data']], + * } + * + * 'attributes' being a JSON string containing the `name` property. + */ + static parseTitle(resource) { + return JSON.parse(resource.values[0][2]).name; + } + + static onError() { + const flash = new Flash('Balsamiq file could not be loaded.'); + + return flash; + } +} + +export default BalsamiqViewer; diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js new file mode 100644 index 00000000000..1dacf84470f --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -0,0 +1,6 @@ +import BalsamiqViewer from './balsamiq/balsamiq_viewer'; + +document.addEventListener('DOMContentLoaded', () => { + const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer')); + balsamiqViewer.loadFile(); +}); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 3062cd51ee3..a20c6ca7a21 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -99,7 +99,7 @@ export default class FileTemplateMediator { }); } - selectTemplateType(item, el, e) { + selectTemplateType(item, e) { if (e) { e.preventDefault(); } @@ -117,6 +117,10 @@ export default class FileTemplateMediator { this.cacheToggleText(); } + selectTemplateTypeOptions(options) { + this.selectTemplateType(options.selectedObj, options.e); + } + selectTemplateFile(selector, query, data) { selector.renderLoading(); // in case undo menu is already already there diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index 31dd45fac89..ab5b3751c4e 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -52,9 +52,17 @@ export default class FileTemplateSelector { .removeClass('fa-spinner fa-spin'); } - reportSelection(query, el, e, data) { + reportSelection(options) { + const { query, e, data } = options; e.preventDefault(); return this.mediator.selectTemplateFile(this, query, data); } + + reportSelectionName(options) { + const opts = options; + opts.query = options.selectedObj.name; + + this.reportSelection(opts); + } } diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js index 216f069ef71..d52d69b1274 100644 --- a/app/assets/javascripts/blob/target_branch_dropdown.js +++ b/app/assets/javascripts/blob/target_branch_dropdown.js @@ -37,8 +37,8 @@ class TargetBranchDropDown { } return SELECT_ITEM_MSG; }, - clicked(item, el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); self.onClick.call(self); }, fieldName: self.fieldName, diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index d7c1c32efbd..888883163c5 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -24,7 +24,7 @@ export default class TemplateSelector { search: { fields: ['name'], }, - clicked: (item, el, e) => this.fetchFileTemplate(item, el, e), + clicked: options => this.fetchFileTemplate(options), text: item => item.name, }); } @@ -51,7 +51,10 @@ export default class TemplateSelector { return this.$dropdownContainer.removeClass('hidden'); } - fetchFileTemplate(item, el, e) { + fetchFileTemplate(options) { + const { e } = options; + const item = options.selectedObj; + e.preventDefault(); return this.requestFile(item); } diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js index 935df07677c..f2f81af137b 100644 --- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js +++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js @@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js index b4b4d09c315..3cb7b960aaa 100644 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js index aefae54ae71..7efda8e7f50 100644 --- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js +++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js @@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js index c8abd689ab4..1d757332f6c 100644 --- a/app/assets/javascripts/blob/template_selectors/license_selector.js +++ b/app/assets/javascripts/blob/template_selectors/license_selector.js @@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => { + clicked: (options) => { + const { e } = options; + const el = options.$el; + const query = options.selectedObj; + const data = { project: this.$dropdown.data('project'), fullname: this.$dropdown.data('fullname'), }; - this.reportSelection(query.id, el, e, data); + this.reportSelection({ + query: query.id, + el, + e, + data, + }); }, text: item => item.name, }); diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js index 56f23ef0568..a09381014a7 100644 --- a/app/assets/javascripts/blob/template_selectors/type_selector.js +++ b/app/assets/javascripts/blob/template_selectors/type_selector.js @@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector { filterable: false, selectable: true, toggleLabel: item => item.name, - clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e), + clicked: options => this.mediator.selectTemplateTypeOptions(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index b6dee8177d2..88eb4251339 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -11,7 +11,7 @@ require('./models/issue'); require('./models/label'); require('./models/list'); require('./models/milestone'); -require('./models/user'); +require('./models/assignee'); require('./stores/boards_store'); require('./stores/modal_store'); require('./services/board_service'); @@ -59,7 +59,8 @@ $(() => { issueLinkBase: $boardApp.dataset.issueLinkBase, rootPath: $boardApp.dataset.rootPath, bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, - detailIssue: Store.detail + detailIssue: Store.detail, + defaultAvatar: $boardApp.dataset.defaultAvatar, }, computed: { detailIssueVisible () { @@ -82,7 +83,7 @@ $(() => { gl.boardService.all() .then((resp) => { resp.json().forEach((board) => { - const list = Store.addList(board); + const list = Store.addList(board, this.defaultAvatar); if (list.type === 'closed') { list.position = Infinity; diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index 0fa85b6fe14..1ce95b62138 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -26,6 +26,7 @@ export default { title: this.title, labels, subscribed: true, + assignees: [], }); this.list.newIssue(issue) diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 004bac09f59..317cef9f227 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -3,8 +3,13 @@ /* global MilestoneSelect */ /* global LabelsSelect */ /* global Sidebar */ +/* global Flash */ import Vue from 'vue'; +import eventHub from '../../sidebar/event_hub'; + +import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; +import Assignees from '../../sidebar/components/assignees/assignees'; require('./sidebar/remove_issue'); @@ -22,11 +27,15 @@ gl.issueBoards.BoardSidebar = Vue.extend({ detail: Store.detail, issue: {}, list: {}, + loadingAssignees: false, }; }, computed: { showSidebar () { return Object.keys(this.issue).length; + }, + assigneeId() { + return this.issue.assignee ? this.issue.assignee.id : 0; } }, watch: { @@ -40,6 +49,10 @@ 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 }, @@ -50,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({ $('.right-sidebar').getNiceScroll().resize(); }); } - } + + this.issue = this.detail.issue; + this.list = this.detail.list; + }, + deep: true }, methods: { closeSidebar () { this.detail.issue = {}; - } + }, + assignSelf () { + // Notify gl dropdown that we are now assigning to current user + this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself')); + + this.addAssignee(this.currentUser); + this.saveAssignees(); + }, + removeAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a); + }, + addAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.addAssignee(a); + }, + removeAllAssignees () { + gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees(); + }, + saveAssignees () { + this.loadingAssignees = true; + + gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint) + .then(() => { + this.loadingAssignees = false; + }) + .catch(() => { + this.loadingAssignees = false; + return new Flash('An error occurred while saving assignees'); + }); + }, + }, + created () { + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); }, mounted () { new IssuableContext(this.currentUser); @@ -67,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }, components: { removeBtn: gl.issueBoards.RemoveIssueBtn, + 'assignee-title': AssigneeTitle, + assignees: Assignees, }, }); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index fc154ee7b8b..710207db0c7 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({ default: false, }, }, + data() { + return { + limitBeforeCounter: 3, + maxRender: 4, + maxCounter: 99, + }; + }, computed: { - cardUrl() { - return `${this.issueLinkBase}/${this.issue.id}`; + numberOverLimit() { + return this.issue.assignees.length - this.limitBeforeCounter; }, - assigneeUrl() { - return `${this.rootPath}${this.issue.assignee.username}`; + assigneeCounterTooltip() { + return `${this.assigneeCounterLabel} more`; + }, + assigneeCounterLabel() { + if (this.numberOverLimit > this.maxCounter) { + return `${this.maxCounter}+`; + } + + return `+${this.numberOverLimit}`; }, - assigneeUrlTitle() { - return `Assigned to ${this.issue.assignee.name}`; + shouldRenderCounter() { + if (this.issue.assignees.length <= this.maxRender) { + return false; + } + + return this.issue.assignees.length > this.numberOverLimit; }, - avatarUrlTitle() { - return `Avatar for ${this.issue.assignee.name}`; + cardUrl() { + return `${this.issueLinkBase}/${this.issue.id}`; }, issueId() { return `#${this.issue.id}`; @@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({ }, }, methods: { + isIndexLessThanlimit(index) { + return index < this.limitBeforeCounter; + }, + shouldRenderAssignee(index) { + // Eg. maxRender is 4, + // Render up to all 4 assignees if there are only 4 assigness + // Otherwise render up to the limitBeforeCounter + if (this.issue.assignees.length <= this.maxRender) { + return index < this.maxRender; + } + + return index < this.limitBeforeCounter; + }, + assigneeUrl(assignee) { + return `${this.rootPath}${assignee.username}`; + }, + assigneeUrlTitle(assignee) { + return `Assigned to ${assignee.name}`; + }, + avatarUrlTitle(assignee) { + return `Avatar for ${assignee.name}`; + }, showLabel(label) { if (!this.list) return true; @@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({ {{ issueId }} </span> </h4> - <a - class="card-assignee has-tooltip js-no-trigger" - :href="assigneeUrl" - :title="assigneeUrlTitle" - v-if="issue.assignee" - data-container="body" - > - <img - class="avatar avatar-inline s20 js-no-trigger" - :src="issue.assignee.avatar" - width="20" - height="20" - :alt="avatarUrlTitle" - /> - </a> + <div class="card-assignee"> + <a + class="has-tooltip js-no-trigger" + :href="assigneeUrl(assignee)" + :title="assigneeUrlTitle(assignee)" + v-for="(assignee, index) in issue.assignees" + v-if="shouldRenderAssignee(index)" + data-container="body" + data-placement="bottom" + > + <img + class="avatar avatar-inline s20" + :src="assignee.avatar" + width="20" + height="20" + :alt="avatarUrlTitle(assignee)" + /> + </a> + <span + class="avatar-counter has-tooltip" + :title="assigneeCounterTooltip" + v-if="shouldRenderCounter" + > + {{ assigneeCounterLabel }} + </span> + </div> </div> - <div class="card-footer" v-if="showLabelFooter"> + <div + class="card-footer" + v-if="showLabelFooter" + > <button - class="label color-label has-tooltip js-no-trigger" + class="label color-label has-tooltip" v-for="label in issue.labels" type="button" v-if="showLabel(label)" diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 7e3bb79af1d..f29b6caa1ac 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => { filterable: true, selectable: true, multiSelect: true, - clicked (label, $el, e) { + clicked (options) { + const { e } = options; + const label = options.selectedObj; e.preventDefault(); if (!Store.findList('title', label.title)) { diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js new file mode 100644 index 00000000000..05dd449e4fd --- /dev/null +++ b/app/assets/javascripts/boards/models/assignee.js @@ -0,0 +1,12 @@ +/* eslint-disable no-unused-vars */ + +class ListAssignee { + constructor(user, defaultAvatar) { + this.id = user.id; + this.name = user.name; + this.username = user.username; + this.avatar = user.avatar_url || defaultAvatar; + } +} + +window.ListAssignee = ListAssignee; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index d6175069e37..6c2d8a3781b 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -1,12 +1,12 @@ /* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ /* global ListLabel */ /* global ListMilestone */ -/* global ListUser */ +/* global ListAssignee */ import Vue from 'vue'; class ListIssue { - constructor (obj) { + constructor (obj, defaultAvatar) { this.globalId = obj.id; this.id = obj.iid; this.title = obj.title; @@ -14,14 +14,10 @@ class ListIssue { this.dueDate = obj.due_date; this.subscribed = obj.subscribed; this.labels = []; + this.assignees = []; this.selected = false; - this.assignee = false; this.position = obj.relative_position || Infinity; - if (obj.assignee) { - this.assignee = new ListUser(obj.assignee); - } - if (obj.milestone) { this.milestone = new ListMilestone(obj.milestone); } @@ -29,6 +25,8 @@ class ListIssue { obj.labels.forEach((label) => { this.labels.push(new ListLabel(label)); }); + + this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar)); } addLabel (label) { @@ -51,6 +49,26 @@ class ListIssue { labels.forEach(this.removeLabel.bind(this)); } + addAssignee (assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(new ListAssignee(assignee)); + } + } + + findAssignee (findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee (removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees () { + this.assignees = []; + } + getLists () { return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); } @@ -60,7 +78,7 @@ class ListIssue { issue: { milestone_id: this.milestone ? this.milestone.id : null, due_date: this.dueDate, - assignee_id: this.assignee ? this.assignee.id : null, + assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0], label_ids: this.labels.map((label) => label.id) } }; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index f2b79a88a4a..bd2f62bcc1a 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -6,7 +6,7 @@ import queryData from '../utils/query_data'; const PER_PAGE = 20; class List { - constructor (obj) { + constructor (obj, defaultAvatar) { this.id = obj.id; this._uid = this.guid(); this.position = obj.position; @@ -18,6 +18,7 @@ class List { this.loadingMore = false; this.issues = []; this.issuesSize = 0; + this.defaultAvatar = defaultAvatar; if (obj.label) { this.label = new ListLabel(obj.label); @@ -106,7 +107,7 @@ class List { createIssues (data) { data.forEach((issueObj) => { - this.addIssue(new ListIssue(issueObj)); + this.addIssue(new ListIssue(issueObj, this.defaultAvatar)); }); } diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js deleted file mode 100644 index 8e9de4d4cbb..00000000000 --- a/app/assets/javascripts/boards/models/user.js +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable no-unused-vars */ - -class ListUser { - constructor(user) { - this.id = user.id; - this.name = user.name; - this.username = user.username; - this.avatar = user.avatar_url; - } -} - -window.ListUser = ListUser; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index ccb00099215..ad9997ac334 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -23,8 +23,8 @@ gl.issueBoards.BoardsStore = { this.state.lists = []; this.filter.path = gl.utils.getUrlParamsArray().join('&'); }, - addList (listObj) { - const list = new List(listObj); + addList (listObj, defaultAvatar) { + const list = new List(listObj, defaultAvatar); this.state.lists.push(list); return list; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index e704be8b53e..ad9c600b499 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -46,6 +46,7 @@ export default Vue.component('pipelines-table', { isLoading: false, hasError: false, isMakingRequest: false, + updateGraphDropdown: false, }; }, @@ -130,15 +131,21 @@ export default Vue.component('pipelines-table', { const pipelines = response.pipelines || response; this.store.storePipelines(pipelines); this.isLoading = false; + this.updateGraphDropdown = true; }, errorCallback() { this.hasError = true; this.isLoading = false; + this.updateGraphDropdown = false; }, setIsMakingRequest(isMakingRequest) { this.isMakingRequest = isMakingRequest; + + if (isMakingRequest) { + this.updateGraphDropdown = false; + } }, }, @@ -163,7 +170,9 @@ export default Vue.component('pipelines-table', { v-if="shouldRenderTable"> <pipelines-table-component :pipelines="state.pipelines" - :service="service" /> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </div> </div> `, diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js new file mode 100644 index 00000000000..ff2f2c81971 --- /dev/null +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -0,0 +1,193 @@ +/* eslint-disable no-new */ +/* global Flash */ +import DropLab from './droplab/drop_lab'; +import ISetter from './droplab/plugins/input_setter'; + +// Todo: Remove this when fixing issue in input_setter plugin +const InputSetter = Object.assign({}, ISetter); + +const CREATE_MERGE_REQUEST = 'create-mr'; +const CREATE_BRANCH = 'create-branch'; + +export default class CreateMergeRequestDropdown { + constructor(wrapperEl) { + this.wrapperEl = wrapperEl; + this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request'); + this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); + this.availableButton = this.wrapperEl.querySelector('.available'); + this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); + this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa'); + this.unavailableButtonText = this.unavailableButton.querySelector('.text'); + + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.canCreatePath = this.wrapperEl.dataset.canCreatePath; + this.createMrPath = this.wrapperEl.dataset.createMrPath; + this.droplabInitialized = false; + this.isCreatingMergeRequest = false; + this.mergeRequestCreated = false; + this.isCreatingBranch = false; + this.branchCreated = false; + + this.init(); + } + + init() { + this.checkAbilityToCreateBranch(); + } + + available() { + this.availableButton.classList.remove('hide'); + this.unavailableButton.classList.add('hide'); + } + + unavailable() { + this.availableButton.classList.add('hide'); + this.unavailableButton.classList.remove('hide'); + } + + enable() { + this.createMergeRequestButton.classList.remove('disabled'); + this.createMergeRequestButton.removeAttribute('disabled'); + + this.dropdownToggle.classList.remove('disabled'); + this.dropdownToggle.removeAttribute('disabled'); + } + + disable() { + this.createMergeRequestButton.classList.add('disabled'); + this.createMergeRequestButton.setAttribute('disabled', 'disabled'); + + this.dropdownToggle.classList.add('disabled'); + this.dropdownToggle.setAttribute('disabled', 'disabled'); + } + + hide() { + this.wrapperEl.classList.add('hide'); + } + + setUnavailableButtonState(isLoading = true) { + if (isLoading) { + this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin'); + this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = 'Checking branch availability…'; + } else { + this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin'); + this.unavailableButtonArrow.classList.add('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = 'New branch unavailable'; + } + } + + checkAbilityToCreateBranch() { + return $.ajax({ + type: 'GET', + dataType: 'json', + url: this.canCreatePath, + beforeSend: () => this.setUnavailableButtonState(), + }) + .done((data) => { + this.setUnavailableButtonState(false); + + if (data.can_create_branch) { + this.available(); + this.enable(); + + if (!this.droplabInitialized) { + this.droplabInitialized = true; + this.initDroplab(); + this.bindEvents(); + } + } else if (data.has_related_branch) { + this.hide(); + } + }).fail(() => { + this.unavailable(); + this.disable(); + new Flash('Failed to check if a new branch can be created.'); + }); + } + + initDroplab() { + this.droplab = new DropLab(); + + this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter], + this.getDroplabConfig()); + } + + getDroplabConfig() { + return { + InputSetter: [{ + input: this.createMergeRequestButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, { + input: this.createMergeRequestButton, + valueAttribute: 'data-text', + }], + }; + } + + bindEvents() { + this.createMergeRequestButton + .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + } + + isBusy() { + return this.isCreatingMergeRequest || + this.mergeRequestCreated || + this.isCreatingBranch || + this.branchCreated; + } + + onClickCreateMergeRequestButton(e) { + let xhr = null; + e.preventDefault(); + + if (this.isBusy()) { + return; + } + + if (e.target.dataset.action === CREATE_MERGE_REQUEST) { + xhr = this.createMergeRequest(); + } else if (e.target.dataset.action === CREATE_BRANCH) { + xhr = this.createBranch(); + } + + xhr.fail(() => { + this.isCreatingMergeRequest = false; + this.isCreatingBranch = false; + }); + + xhr.always(() => this.enable()); + + this.disable(); + } + + createMergeRequest() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createMrPath, + beforeSend: () => (this.isCreatingMergeRequest = true), + }) + .done((data) => { + this.mergeRequestCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create Merge Request. Please try again.')); + } + + createBranch() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createBranchPath, + beforeSend: () => (this.isCreatingBranch = true), + }) + .done((data) => { + this.branchCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); + } +} diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index abe48572347..8d3d34f836f 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -9,9 +9,9 @@ export default { <span v-if="count === 50" class="events-info pull-right"> <i class="fa fa-warning has-tooltip" aria-hidden="true" - title="Limited to showing 50 events at most" + :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)" data-placement="top"></i> - Showing 50 events + {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> `, }; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index 80bd2df6f42..0d9ad197abf 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - Opened + {{ __('OpenedNDaysAgo|Opened') }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - by + {{ __('ByAuthor|by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> </div> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index 20a43798fbe..ad285874643 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - Opened + {{ __('OpenedNDaysAgo|Opened') }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - by + {{ __('ByAuthor|by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index f33cac3da82..222084deee9 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -31,10 +31,10 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ </a> </h5> <span> - First + {{ __('FirstPushedBy|First') }} <span class="commit-icon">${iconCommit}</span> <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> - pushed by + {{ __('FirstPushedBy|pushed by') }} <a :href="commit.author.webUrl" class="commit-author-link"> {{ commit.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 657f5385374..a14ebc3ece9 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - Opened + {{ __('OpenedNDaysAgo|Opened') }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - by + {{ __('ByAuthor|by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index 8a801300647..1a5bf9bc0b5 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - Opened + {{ __('OpenedNDaysAgo|Opened') }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - by + {{ __('ByAuthor|by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> <template v-if="mergeRequest.state === 'closed'"> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index 4a286379588..b1e9362434f 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ </h5> <span> <a :href="build.url" class="build-date">{{ build.date }}</a> - by + {{ __('ByAuthor|by') }} <a :href="build.author.webUrl" class="issue-author-link"> {{ build.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index 77edcb76273..d5e6167b2a8 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -12,10 +12,10 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({ template: ` <span class="total-time"> <template v-if="Object.keys(time).length"> - <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template> - <template v-if="time.hours">{{ time.hours }} <span>hr</span></template> - <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template> - <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> + <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template> + <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template> + <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template> + <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template> </template> <template v-else> -- diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 48cab437e02..c8e53cb554e 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; +import Translate from '../vue_shared/translate'; import LimitWarningComponent from './components/limit_warning_component'; require('./components/stage_code_component'); @@ -16,6 +17,8 @@ require('./cycle_analytics_service'); require('./cycle_analytics_store'); require('./default_event_objects'); +Vue.use(Translate); + $(() => { const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; const cycleAnalyticsEl = document.querySelector('#cycle-analytics'); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js index 681d6eef565..6504d7db2f2 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -30,7 +30,7 @@ class CycleAnalyticsService { startDate, } = options; - return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { + return $.get(`${this.requestPath}/events/${stage.name}.json`, { cycle_analytics: { start_date: startDate, }, diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 6536a8fd7fa..50bd394e90e 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -1,4 +1,5 @@ /* eslint-disable no-param-reassign */ +import { __ } from '../locale'; require('../lib/utils/text_utility'); const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); @@ -7,13 +8,13 @@ const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; const EMPTY_STAGE_TEXTS = { - issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', + issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'), + plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'), + code: __('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'), + test: __('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'), + review: __('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'), + staging: __('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'), + production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'), }; global.cycleAnalytics.CycleAnalyticsStore = { @@ -38,7 +39,7 @@ global.cycleAnalytics.CycleAnalyticsStore = { }); newData.stages.forEach((item) => { - const stageSlug = gl.text.dasherize(item.title.toLowerCase()); + const stageSlug = gl.text.dasherize(item.name.toLowerCase()); item.active = false; item.isUserAllowed = data.permissions[stageSlug]; item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue new file mode 100644 index 00000000000..3ff3a9d977e --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -0,0 +1,54 @@ +<script> + import eventHub from '../eventhub'; + + export default { + data() { + return { + isLoading: false, + }; + }, + props: { + deployKey: { + type: Object, + required: true, + }, + type: { + type: String, + required: true, + }, + btnCssClass: { + type: String, + required: false, + default: 'btn-default', + }, + }, + methods: { + doAction() { + this.isLoading = true; + + eventHub.$emit(`${this.type}.key`, this.deployKey); + }, + }, + computed: { + text() { + return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`; + }, + }, + }; +</script> + +<template> + <button + class="btn btn-sm prepend-left-10" + :class="[{ disabled: isLoading }, btnCssClass]" + :disabled="isLoading" + @click="doAction"> + {{ text }} + <i + v-if="isLoading" + class="fa fa-spinner fa-spin" + aria-hidden="true" + aria-label="Loading"> + </i> + </button> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue new file mode 100644 index 00000000000..7315a9e11cb --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -0,0 +1,102 @@ +<script> + /* global Flash */ + import eventHub from '../eventhub'; + import DeployKeysService from '../service'; + import DeployKeysStore from '../store'; + import keysPanel from './keys_panel.vue'; + + export default { + data() { + return { + isLoading: false, + store: new DeployKeysStore(), + }; + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + computed: { + hasKeys() { + return Object.keys(this.keys).length; + }, + keys() { + return this.store.keys; + }, + }, + components: { + keysPanel, + }, + methods: { + fetchKeys() { + this.isLoading = true; + + this.service.getKeys() + .then((data) => { + this.isLoading = false; + this.store.keys = data; + }) + .catch(() => new Flash('Error getting deploy keys')); + }, + enableKey(deployKey) { + this.service.enableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error enabling deploy key')); + }, + disableKey(deployKey) { + // eslint-disable-next-line no-alert + if (confirm('You are going to remove this deploy key. Are you sure?')) { + this.service.disableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error removing deploy key')); + } + }, + }, + created() { + this.service = new DeployKeysService(this.endpoint); + + eventHub.$on('enable.key', this.enableKey); + eventHub.$on('remove.key', this.disableKey); + eventHub.$on('disable.key', this.disableKey); + }, + mounted() { + this.fetchKeys(); + }, + beforeDestroy() { + eventHub.$off('enable.key', this.enableKey); + eventHub.$off('remove.key', this.disableKey); + eventHub.$off('disable.key', this.disableKey); + }, + }; +</script> + +<template> + <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys"> + <div + class="text-center" + v-if="isLoading && !hasKeys"> + <i + class="fa fa-spinner fa-spin fa-2x" + aria-hidden="true" + aria-label="Loading deploy keys"> + </i> + </div> + <div v-else-if="hasKeys"> + <keys-panel + title="Enabled deploy keys for this project" + :keys="keys.enabled_keys" + :store="store" /> + <keys-panel + title="Deploy keys from projects you have access to" + :keys="keys.available_project_keys" + :store="store" /> + <keys-panel + v-if="keys.public_keys.length" + title="Public deploy keys available to any project" + :keys="keys.public_keys" + :store="store" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue new file mode 100644 index 00000000000..0a06a481b96 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -0,0 +1,80 @@ +<script> + import actionBtn from './action_btn.vue'; + + export default { + props: { + deployKey: { + type: Object, + required: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + actionBtn, + }, + computed: { + timeagoDate() { + return gl.utils.getTimeago().format(this.deployKey.created_at); + }, + }, + methods: { + isEnabled(id) { + return this.store.findEnabledKey(id) !== undefined; + }, + }, + }; +</script> + +<template> + <div> + <div class="pull-left append-right-10 hidden-xs"> + <i + aria-hidden="true" + class="fa fa-key key-icon"> + </i> + </div> + <div class="deploy-key-content key-list-item-info"> + <strong class="title"> + {{ deployKey.title }} + </strong> + <div class="description"> + {{ deployKey.fingerprint }} + </div> + <div + v-if="deployKey.can_push" + class="write-access-allowed"> + Write access allowed + </div> + </div> + <div class="deploy-key-content prepend-left-default deploy-key-projects"> + <a + v-for="project in deployKey.projects" + class="label deploy-project-label" + :href="project.full_path"> + {{ project.full_name }} + </a> + </div> + <div class="deploy-key-content"> + <span class="key-created-at"> + created {{ timeagoDate }} + </span> + <action-btn + v-if="!isEnabled(deployKey.id)" + :deploy-key="deployKey" + type="enable"/> + <action-btn + v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="remove" /> + <action-btn + v-else + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="disable" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue new file mode 100644 index 00000000000..eccc470578b --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -0,0 +1,52 @@ +<script> + import key from './key.vue'; + + export default { + props: { + title: { + type: String, + required: true, + }, + keys: { + type: Array, + required: true, + }, + showHelpBox: { + type: Boolean, + required: false, + default: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + key, + }, + }; +</script> + +<template> + <div class="deploy-keys-panel"> + <h5> + {{ title }} + ({{ keys.length }}) + </h5> + <ul class="well-list" + v-if="keys.length"> + <li + v-for="deployKey in keys" + :key="deployKey.id"> + <key + :deploy-key="deployKey" + :store="store" /> + </li> + </ul> + <div + class="settings-message text-center" + v-else-if="showHelpBox"> + No deploy keys found. Create one with the form above. + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/eventhub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js new file mode 100644 index 00000000000..a5f232f950a --- /dev/null +++ b/app/assets/javascripts/deploy_keys/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import deployKeysApp from './components/app.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: document.getElementById('js-deploy-keys'), + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + }; + }, + components: { + deployKeysApp, + }, + render(createElement) { + return createElement('deploy-keys-app', { + props: { + endpoint: this.endpoint, + }, + }); + }, +})); diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js new file mode 100644 index 00000000000..fe6dbaa9498 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/service/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class DeployKeysService { + constructor(endpoint) { + this.endpoint = endpoint; + + this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, { + enable: { + method: 'PUT', + url: `${this.endpoint}{/id}/enable`, + }, + disable: { + method: 'PUT', + url: `${this.endpoint}{/id}/disable`, + }, + }); + } + + getKeys() { + return this.resource.get() + .then(response => response.json()); + } + + enableKey(id) { + return this.resource.enable({ id }, {}); + } + + disableKey(id) { + return this.resource.disable({ id }, {}); + } +} diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js new file mode 100644 index 00000000000..6210361af26 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/store/index.js @@ -0,0 +1,9 @@ +export default class DeployKeysStore { + constructor() { + this.keys = {}; + } + + findEnabledKey(id) { + return this.keys.enabled_keys.find(key => key.id === id); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 0bdce52cc89..b16ff2a0221 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -50,6 +50,7 @@ import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; import BlobViewer from './blob/viewer/index'; +import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -198,6 +199,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); new LabelsSelect(); new MilestoneSelect(); new gl.IssuableTemplateSelectors(); + new AutoWidthDropdownSelect($('.js-target-branch-select')).init(); break; case 'projects:tags:new': new ZenMode(); @@ -250,6 +252,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); } break; case 'projects:pipelines:builds': + case 'projects:pipelines:failures': case 'projects:pipelines:show': const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`; @@ -344,6 +347,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:artifacts:browse': new BuildArtifacts(); break; + case 'projects:artifacts:file': + new BlobViewer(); + break; case 'help:index': gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); break; diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index 8883ed9aa14..868d47e91b3 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -3,11 +3,14 @@ const DATA_DROPDOWN = 'data-dropdown'; const SELECTED_CLASS = 'droplab-item-selected'; const ACTIVE_CLASS = 'droplab-item-active'; const IGNORE_CLASS = 'droplab-item-ignore'; +// Matches `{{anything}}` and `{{ everything }}`. +const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; export { DATA_TRIGGER, DATA_DROPDOWN, SELECTED_CLASS, ACTIVE_CLASS, + TEMPLATE_REGEX, IGNORE_CLASS, }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 1fb4d63923c..de3927d683c 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -94,7 +94,7 @@ Object.assign(DropDown.prototype, { }, renderChildren: function(data) { - var html = utils.t(this.templateString, data); + var html = utils.template(this.templateString, data); var template = document.createElement('div'); template.innerHTML = html; diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js index c149a33a1e9..4da7344604e 100644 --- a/app/assets/javascripts/droplab/utils.js +++ b/app/assets/javascripts/droplab/utils.js @@ -1,19 +1,19 @@ /* eslint-disable */ -import { DATA_TRIGGER, DATA_DROPDOWN } from './constants'; +import { template as _template } from 'underscore'; +import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants'; const utils = { toCamelCase(attr) { return this.camelize(attr.split('-').slice(1).join(' ')); }, - t(s, d) { - for (const p in d) { - if (Object.prototype.hasOwnProperty.call(d, p)) { - s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]); - } - } - return s; + template(templateString, data) { + const template = _template(templateString, { + escape: TEMPLATE_REGEX, + }); + + return template(data); }, camelize(str) { diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index b70d242269d..b3a76fbb43e 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -5,7 +5,7 @@ require('./preview_markdown'); window.DropzoneInput = (function() { function DropzoneInput(form) { - var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, project_uploads_path, showError, showSpinner, uploadFile, uploadProgress; + var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, uploads_path, showError, showSpinner, uploadFile, uploadProgress; Dropzone.autoDiscover = false; alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; @@ -16,7 +16,7 @@ window.DropzoneInput = (function() { iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>"; uploadProgress = $("<div class=\"div-dropzone-progress\"></div>"); btnAlert = "<button type=\"button\"" + alertAttr + ">×</button>"; - project_uploads_path = window.project_uploads_path || null; + uploads_path = window.uploads_path || null; max_file_size = gon.max_file_size || 10; form_textarea = $(form).find(".js-gfm-input"); form_textarea.wrap("<div class=\"div-dropzone\"></div>"); @@ -39,10 +39,10 @@ window.DropzoneInput = (function() { "display": "none" }); - if (!project_uploads_path) return; + if (!uploads_path) return; dropzone = form_dropzone.dropzone({ - url: project_uploads_path, + url: uploads_path, dictDefaultMessage: "", clickable: true, paramName: "file", @@ -159,7 +159,7 @@ window.DropzoneInput = (function() { formData = new FormData(); formData.append("file", item, filename); return $.ajax({ - url: project_uploads_path, + url: uploads_path, type: "POST", data: formData, dataType: "json", diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue index f319d6ca0c8..e0088d496eb 100644 --- a/app/assets/javascripts/environments/components/environment.vue +++ b/app/assets/javascripts/environments/components/environment.vue @@ -1,6 +1,4 @@ <script> - -/* eslint-disable no-new */ /* global Flash */ import EnvironmentsService from '../services/environments_service'; import EnvironmentTable from './environments_table.vue'; @@ -71,11 +69,13 @@ export default { eventHub.$on('refreshEnvironments', this.fetchEnvironments); eventHub.$on('toggleFolder', this.toggleFolder); + eventHub.$on('postAction', this.postAction); }, beforeDestroyed() { eventHub.$off('refreshEnvironments'); eventHub.$off('toggleFolder'); + eventHub.$off('postAction'); }, methods: { @@ -122,6 +122,7 @@ export default { }) .catch(() => { this.isLoading = false; + // eslint-disable-next-line no-new new Flash('An error occurred while fetching the environments.'); }); }, @@ -137,9 +138,16 @@ export default { }) .catch(() => { this.isLoadingFolderContent = false; + // eslint-disable-next-line no-new new Flash('An error occurred while fetching the environments.'); }); }, + + postAction(endpoint) { + this.service.postAction(endpoint) + .then(() => this.fetchEnvironments()) + .catch(() => new Flash('An error occured while making the request.')); + }, }, }; </script> @@ -217,7 +225,6 @@ export default { :environments="state.environments" :can-create-deployment="canCreateDeploymentParsed" :can-read-environment="canReadEnvironmentParsed" - :service="service" :is-loading-folder-content="isLoadingFolderContent" /> </div> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index e81c97260d7..63bffe8a998 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,7 +1,4 @@ <script> -/* global Flash */ -/* eslint-disable no-new */ - import playIconSvg from 'icons/_icon_play.svg'; import eventHub from '../event_hub'; @@ -12,11 +9,6 @@ export default { required: false, default: () => [], }, - - service: { - type: Object, - required: true, - }, }, data() { @@ -38,15 +30,7 @@ export default { $(this.$refs.tooltip).tooltip('destroy'); - this.service.postAction(endpoint) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.'); - }); + eventHub.$emit('postAction', endpoint); }, isActionDisabled(action) { diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 73679de6039..0ffe9ea17fa 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -46,11 +46,6 @@ export default { required: false, default: false, }, - - service: { - type: Object, - required: true, - }, }, computed: { @@ -543,31 +538,34 @@ export default { <actions-component v-if="hasManualActions && canCreateDeployment" - :service="service" - :actions="manualActions"/> + :actions="manualActions" + /> <external-url-component v-if="externalURL && canReadEnvironment" - :external-url="externalURL"/> + :external-url="externalURL" + /> <monitoring-button-component v-if="monitoringUrl && canReadEnvironment" - :monitoring-url="monitoringUrl"/> + :monitoring-url="monitoringUrl" + /> <terminal-button-component v-if="model && model.terminal_path" - :terminal-path="model.terminal_path"/> + :terminal-path="model.terminal_path" + /> <stop-component v-if="hasStopAction && canCreateDeployment" :stop-url="model.stop_path" - :service="service"/> + /> <rollback-component v-if="canRetry && canCreateDeployment" :is-last-deployment="isLastDeployment" :retry-url="retryUrl" - :service="service"/> + /> </div> </td> </tr> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index f139f24036f..44b8730fd09 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -1,6 +1,4 @@ <script> -/* global Flash */ -/* eslint-disable no-new */ /** * Renders Rollback or Re deploy button in environments table depending * of the provided property `isLastDeployment`. @@ -20,11 +18,6 @@ export default { type: Boolean, default: true, }, - - service: { - type: Object, - required: true, - }, }, data() { @@ -37,17 +30,7 @@ export default { onClick() { this.isLoading = true; - $(this.$el).tooltip('destroy'); - - this.service.postAction(this.retryUrl) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.'); - }); + eventHub.$emit('postAction', this.retryUrl); }, }, }; diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index 11e9aff7b92..f483ea7e937 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -1,6 +1,4 @@ <script> -/* global Flash */ -/* eslint-disable no-new, no-alert */ /** * Renders the stop "button" that allows stop an environment. * Used in environments table. @@ -13,11 +11,6 @@ export default { type: String, default: '', }, - - service: { - type: Object, - required: true, - }, }, data() { @@ -34,20 +27,13 @@ export default { methods: { onClick() { + // eslint-disable-next-line no-alert if (confirm('Are you sure you want to stop this environment?')) { this.isLoading = true; $(this.$el).tooltip('destroy'); - this.service.postAction(this.retryUrl) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.', 'alert'); - }); + eventHub.$emit('postAction', this.stopUrl); } }, }, diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 87f7cb4a536..15eedaf76e1 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -28,11 +28,6 @@ export default { default: false, }, - service: { - type: Object, - required: true, - }, - isLoadingFolderContent: { type: Boolean, required: false, @@ -78,7 +73,7 @@ export default { :model="model" :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" - :service="service" /> + /> <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> <tr v-if="isLoadingFolderContent"> @@ -96,7 +91,7 @@ export default { :model="children" :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" - :service="service" /> + /> <tr> <td diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index d27b2acfcdf..f4a0c390c91 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable no-new */ /* global Flash */ import EnvironmentsService from '../services/environments_service'; import EnvironmentTable from '../components/environments_table.vue'; @@ -99,6 +98,7 @@ export default { }) .catch(() => { this.isLoading = false; + // eslint-disable-next-line no-new new Flash('An error occurred while fetching the environments.', 'alert'); }); }, @@ -169,7 +169,7 @@ export default { :environments="state.environments" :can-create-deployment="canCreateDeploymentParsed" :can-read-environment="canReadEnvironmentParsed" - :service="service"/> + /> <table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js index 9126422b335..15052dbd362 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -8,6 +8,11 @@ export default { type: Array, required: true, }, + isLocalStorageAvailable: { + type: Boolean, + required: false, + default: true, + }, }, computed: { @@ -47,7 +52,12 @@ export default { template: ` <div> - <ul v-if="hasItems"> + <div + v-if="!isLocalStorageAvailable" + class="dropdown-info-note"> + This feature requires local storage to be enabled + </div> + <ul v-else-if="hasItems"> <li v-for="(item, index) in processedItems" :key="index"> diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 3e7a892756c..5e9434fd48f 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -62,7 +62,7 @@ class DropdownHint extends gl.FilteredSearchDropdown { Object.assign({ icon: `fa-${icon}`, hint, - tag: `<${tag}>`, + tag: `<${tag}>`, }, type && { type }), ); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 36af0674ac6..9fea563370f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,5 +1,3 @@ -/* global Flash */ - import FilteredSearchContainer from './container'; import RecentSearchesRoot from './recent_searches_root'; import RecentSearchesStore from './stores/recent_searches_store'; @@ -15,7 +13,9 @@ class FilteredSearchManager { this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; - this.recentSearchesStore = new RecentSearchesStore(); + this.recentSearchesStore = new RecentSearchesStore({ + isLocalStorageAvailable: RecentSearchesService.isAvailable(), + }); let recentSearchesKey = 'issue-recent-searches'; if (page === 'merge_requests') { recentSearchesKey = 'merge-request-recent-searches'; @@ -24,9 +24,10 @@ class FilteredSearchManager { // Fetch recent searches from localStorage this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() - .catch(() => { + .catch((error) => { + if (error.name === 'RecentSearchesServiceError') return undefined; // eslint-disable-next-line no-new - new Flash('An error occured while parsing recent searches'); + new window.Flash('An error occured while parsing recent searches'); // Gracefully fail to empty array return []; }) diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 453ecccc6fc..f3003b86493 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import '~/flash'; /* global Flash */ import FilteredSearchContainer from './container'; class FilteredSearchVisualTokens { @@ -48,6 +50,40 @@ class FilteredSearchVisualTokens { `; } + static updateLabelTokenColor(tokenValueContainer, tokenValue) { + const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); + const baseEndpoint = filteredSearchInput.dataset.baseEndpoint; + const labelsEndpoint = `${baseEndpoint}/labels.json`; + + return AjaxCache.retrieve(labelsEndpoint) + .then((labels) => { + const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue); + + if (!matchingLabel) { + return; + } + + const tokenValueStyle = tokenValueContainer.style; + tokenValueStyle.backgroundColor = matchingLabel.color; + tokenValueStyle.color = matchingLabel.text_color; + + if (matchingLabel.text_color === '#FFFFFF') { + const removeToken = tokenValueContainer.querySelector('.remove-token'); + removeToken.classList.add('inverted'); + } + }) + .catch(() => new Flash('An error occurred while fetching label colors.')); + } + + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + const tokenValueContainer = parentElement.querySelector('.value-container'); + tokenValueContainer.querySelector('.value').innerText = tokenValue; + + if (tokenName.toLowerCase() === 'label') { + FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); + } + } + static addVisualTokenElement(name, value, isSearchTerm) { const li = document.createElement('li'); li.classList.add('js-visual-token'); @@ -55,7 +91,7 @@ class FilteredSearchVisualTokens { if (value) { li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); - li.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { li.innerHTML = '<div class="name"></div>'; } @@ -74,7 +110,7 @@ class FilteredSearchVisualTokens { const name = FilteredSearchVisualTokens.getLastTokenPartial(); lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); lastVisualToken.querySelector('.name').innerText = name; - lastVisualToken.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value); } } @@ -183,6 +219,9 @@ class FilteredSearchVisualTokens { static moveInputToTheRight() { const input = FilteredSearchContainer.container.querySelector('.filtered-search'); + + if (!input) return; + const inputLi = input.parentElement; const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js index 4e38409e12a..b2e6f63aacf 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_root.js +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -29,12 +29,15 @@ class RecentSearchesRoot { } render() { + const state = this.store.state; this.vm = new Vue({ el: this.wrapperElement, - data: this.store.state, + data() { return state; }, template: ` <recent-searches-dropdown-content - :items="recentSearches" /> + :items="recentSearches" + :is-local-storage-available="isLocalStorageAvailable" + /> `, components: { 'recent-searches-dropdown-content': RecentSearchesDropdownContent, diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js index 3e402d5aed0..a056dea928d 100644 --- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -1,9 +1,17 @@ +import RecentSearchesServiceError from './recent_searches_service_error'; +import AccessorUtilities from '../../lib/utils/accessor'; + class RecentSearchesService { constructor(localStorageKey = 'issuable-recent-searches') { this.localStorageKey = localStorageKey; } fetch() { + if (!RecentSearchesService.isAvailable()) { + const error = new RecentSearchesServiceError(); + return Promise.reject(error); + } + const input = window.localStorage.getItem(this.localStorageKey); let searches = []; @@ -19,8 +27,14 @@ class RecentSearchesService { } save(searches = []) { + if (!RecentSearchesService.isAvailable()) return; + window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches)); } + + static isAvailable() { + return AccessorUtilities.isLocalStorageAccessSafe(); + } } export default RecentSearchesService; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js new file mode 100644 index 00000000000..5917b223d63 --- /dev/null +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js @@ -0,0 +1,11 @@ +class RecentSearchesServiceError { + constructor(message) { + this.name = 'RecentSearchesServiceError'; + this.message = message || 'Recent Searches Service is unavailable'; + } +} + +// Can't use `extends` for builtin prototypes and get true inheritance yet +RecentSearchesServiceError.prototype = Error.prototype; + +export default RecentSearchesServiceError; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 687a462a0d4..f1b99023c72 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -101,9 +101,17 @@ window.gl.GfmAutoComplete = { } } }, - setup: function(input) { + setup: function(input, enableMap = { + emojis: true, + members: true, + issues: true, + milestones: true, + mergeRequests: true, + labels: true + }) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); + this.enableMap = enableMap; this.setupLifecycle(); }, setupLifecycle() { @@ -115,7 +123,84 @@ window.gl.GfmAutoComplete = { $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); }); }, + setupAtWho: function($input) { + if (this.enableMap.emojis) this.setupEmoji($input); + if (this.enableMap.members) this.setupMembers($input); + if (this.enableMap.issues) this.setupIssues($input); + if (this.enableMap.milestones) this.setupMilestones($input); + if (this.enableMap.mergeRequests) this.setupMergeRequests($input); + if (this.enableMap.labels) this.setupLabels($input); + + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + $input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + skipSpecialCharacterTest: true, + data: this.defaultLoadingData, + displayTpl: function(value) { + if (this.isLoading(value)) return this.Loading.template; + var tpl = '<li>/${name}'; + if (value.aliases.length > 0) { + tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; + } + if (value.params.length > 0) { + tpl += ' <small><%- params.join(" ") %></small>'; + } + if (value.description !== '') { + tpl += '<small class="description"><i><%- description %></i></small>'; + } + tpl += '</li>'; + return _.template(tpl)(value); + }.bind(this), + insertTpl: function(value) { + var tpl = "/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; + } + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + if (gl.GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); + }, + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; + } + } + } + }); + return; + }, + + setupEmoji($input) { // Emoji $input.atwho({ at: ':', @@ -139,6 +224,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMembers($input) { // Team Members $input.atwho({ at: '@', @@ -180,6 +268,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupIssues($input) { $input.atwho({ at: '#', alias: 'issues', @@ -208,6 +299,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMilestones($input) { $input.atwho({ at: '%', alias: 'milestones', @@ -236,6 +330,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMergeRequests($input) { $input.atwho({ at: '!', alias: 'mergerequests', @@ -264,6 +361,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupLabels($input) { $input.atwho({ at: '~', alias: 'labels', @@ -298,73 +398,8 @@ window.gl.GfmAutoComplete = { } } }); - // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - $input.filter('[data-supports-slash-commands="true"]').atwho({ - at: '/', - alias: 'commands', - searchKey: 'search', - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '<li>/${name}'; - if (value.aliases.length > 0) { - tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; - } - if (value.params.length > 0) { - tpl += ' <small><%- params.join(" ") %></small>'; - } - if (value.description !== '') { - tpl += '<small class="description"><i><%- description %></i></small>'; - } - tpl += '</li>'; - return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; - if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; - } - } - return _.template(tpl)({ reference_prefix: reference_prefix }); - }, - suffix: '', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; - if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); - } - return { - name: c.name, - aliases: c.aliases, - params: c.params, - description: c.description, - search: search - }; - }); - }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); - if (match) { - return match[1]; - } else { - return null; - } - } - } - }); - return; }, + fetchData: function($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index a03f1202a6d..0c9eb84f0eb 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -255,7 +255,8 @@ GitLabDropdown = (function() { } }; // Remote data - })(this) + })(this), + instance: this, }); } } @@ -269,6 +270,7 @@ GitLabDropdown = (function() { remote: this.options.filterRemote, query: this.options.data, keys: searchFields, + instance: this, elements: (function(_this) { return function() { selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; @@ -343,21 +345,26 @@ GitLabDropdown = (function() { } this.dropdown.on("click", selector, function(e) { var $el, selected, selectedObj, isMarking; - $el = $(this); + $el = $(e.currentTarget); selected = self.rowClicked($el); selectedObj = selected ? selected[0] : null; isMarking = selected ? selected[1] : null; - if (self.options.clicked) { - self.options.clicked(selectedObj, $el, e, isMarking); + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); } // Update label right after all modifications in dropdown has been done - if (self.options.toggleLabel) { - self.updateLabel(selectedObj, $el, self); + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); } $el.trigger('blur'); - }); + }.bind(this)); } } @@ -439,15 +446,34 @@ GitLabDropdown = (function() { } }; + GitLabDropdown.prototype.filteredFullData = function() { + return this.fullData.filter(r => typeof r === 'object' + && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') + && !Object.prototype.hasOwnProperty.call(r, 'header') + ); + }; + GitLabDropdown.prototype.opened = function(e) { var contentHtml; this.resetRows(); this.addArrowKeyEvent(); + const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); + const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); + const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); + // Makes indeterminate items effective - if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + if (this.fullData && hasFilterBulkUpdate) { this.parseData(this.fullData); } + + // Process the data to make sure rendered data + // matches the correct layout + if (this.fullData && hasMultiSelect && this.options.processData) { + const inputValue = this.filterInput.val(); + this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); + } + contentHtml = $('.dropdown-content', this.dropdown).html(); if (this.remote && contentHtml === "") { this.remote.execute(); @@ -709,6 +735,11 @@ GitLabDropdown = (function() { if (this.options.inputId != null) { $input.attr('id', this.options.inputId); } + + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } + return this.dropdown.before($input); }; @@ -829,7 +860,14 @@ GitLabDropdown = (function() { if (instance == null) { instance = null; } - return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance)); + + let toggleText = this.options.toggleLabel(selected, el, instance); + if (this.options.updateLabel) { + // Option to override the dropdown label text + toggleText = this.options.updateLabel; + } + + return $(this.el).find(".dropdown-toggle-text").text(toggleText); }; GitLabDropdown.prototype.clearField = function(field, isInput) { diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index ff10f19a4fe..ff06092e4d6 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -34,9 +34,9 @@ GLForm.prototype.setupForm = function() { gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); - // form and textarea event listeners - this.addEventListeners(); } + // form and textarea event listeners + this.addEventListeners(); gl.text.init(this.form); // hide discard button this.form.find('.js-note-discard').hide(); diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js new file mode 100644 index 00000000000..2203a56315e --- /dev/null +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -0,0 +1,38 @@ +let instanceCount = 0; + +class AutoWidthDropdownSelect { + constructor(selectElement) { + this.$selectElement = $(selectElement); + this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`; + instanceCount += 1; + } + + init() { + const dropdownClass = this.dropdownClass; + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + dropdownCss() { + let resultantWidth = 'auto'; + const $dropdown = $(`.${dropdownClass}`); + + // We have to look at the parent because + // `offsetParent` on a `display: none;` is `null` + const offsetParentWidth = $(this).parent().offsetParent().width(); + // Reset any width to let it naturally flow + $dropdown.css('width', 'auto'); + if ($dropdown.outerWidth(false) > offsetParentWidth) { + resultantWidth = offsetParentWidth; + } + + return { + width: resultantWidth, + maxWidth: offsetParentWidth, + }; + }, + }); + + return this; + } +} + +export default AutoWidthDropdownSelect; diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js deleted file mode 100644 index e927cc0077c..00000000000 --- a/app/assets/javascripts/issuable/issuable_bundle.js +++ /dev/null @@ -1 +0,0 @@ -require('./time_tracking/time_tracking_bundle'); diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js deleted file mode 100644 index aec13e78f42..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import stopwatchSvg from 'icons/_icon_stopwatch.svg'; - -require('../../../lib/utils/pretty_time'); - -(() => { - Vue.component('time-tracking-collapsed-state', { - name: 'time-tracking-collapsed-state', - props: [ - 'showComparisonState', - 'showSpentOnlyState', - 'showEstimateOnlyState', - 'showNoTimeTrackingState', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - methods: { - abbreviateTime(timeStr) { - return gl.utils.prettyTime.abbreviateTime(timeStr); - }, - }, - template: ` - <div class='sidebar-collapsed-icon'> - ${stopwatchSvg} - <div class='time-tracking-collapsed-summary'> - <div class='compare' v-if='showComparisonState'> - <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='estimate-only' v-if='showEstimateOnlyState'> - <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='spend-only' v-if='showSpentOnlyState'> - <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span> - </div> - <div class='no-tracking' v-if='showNoTimeTrackingState'> - <span class='no-value'>None</span> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js deleted file mode 100644 index c55e263f6f4..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js +++ /dev/null @@ -1,70 +0,0 @@ -import Vue from 'vue'; - -require('../../../lib/utils/pretty_time'); - -(() => { - const prettyTime = gl.utils.prettyTime; - - Vue.component('time-tracking-comparison-pane', { - name: 'time-tracking-comparison-pane', - props: [ - 'timeSpent', - 'timeEstimate', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - computed: { - parsedRemaining() { - const diffSeconds = this.timeEstimate - this.timeSpent; - return prettyTime.parseSeconds(diffSeconds); - }, - timeRemainingHumanReadable() { - return prettyTime.stringifyTime(this.parsedRemaining); - }, - timeRemainingTooltip() { - const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; - return `${prefix} ${this.timeRemainingHumanReadable}`; - }, - /* Diff values for comparison meter */ - timeRemainingMinutes() { - return this.timeEstimate - this.timeSpent; - }, - timeRemainingPercent() { - return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; - }, - timeRemainingStatusClass() { - return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; - }, - /* Parsed time values */ - parsedEstimate() { - return prettyTime.parseSeconds(this.timeEstimate); - }, - parsedSpent() { - return prettyTime.parseSeconds(this.timeSpent); - }, - }, - template: ` - <div class='time-tracking-comparison-pane'> - <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay' - :aria-valuenow='timeRemainingTooltip' - :title='timeRemainingTooltip' - :data-original-title='timeRemainingTooltip' - :class='timeRemainingStatusClass'> - <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'> - <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div> - </div> - <div class='compare-display-container'> - <div class='compare-display pull-left'> - <span class='compare-label'>Spent</span> - <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span> - </div> - <div class='compare-display estimated pull-right'> - <span class='compare-label'>Est</span> - <span class='compare-value'>{{ timeEstimateHumanReadable }}</span> - </div> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js deleted file mode 100644 index a7fbd704c40..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-estimate-only-pane', { - name: 'time-tracking-estimate-only-pane', - props: ['timeEstimateHumanReadable'], - template: ` - <div class='time-tracking-estimate-only-pane'> - <span class='bold'>Estimated:</span> - {{ timeEstimateHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js deleted file mode 100644 index 344b29ebea4..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js +++ /dev/null @@ -1,25 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-help-state', { - name: 'time-tracking-help-state', - props: ['docsUrl'], - template: ` - <div class='time-tracking-help-state'> - <div class='time-tracking-info'> - <h4>Track time with slash commands</h4> - <p>Slash commands can be used in the issues description and comment boxes.</p> - <p> - <code>/estimate</code> - will update the estimated time with the latest command. - </p> - <p> - <code>/spend</code> - will update the sum of the time spent. - </p> - <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js deleted file mode 100644 index b081adf5e64..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-no-tracking-pane', { - name: 'time-tracking-no-tracking-pane', - template: ` - <div class='time-tracking-no-tracking-pane'> - <span class='no-value'>No estimate or time spent</span> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js deleted file mode 100644 index edb9169112f..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-spent-only-pane', { - name: 'time-tracking-spent-only-pane', - props: ['timeSpentHumanReadable'], - template: ` - <div class='time-tracking-spend-only-pane'> - <span class='bold'>Spent:</span> - {{ timeSpentHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js deleted file mode 100644 index 0213522f551..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js +++ /dev/null @@ -1,117 +0,0 @@ -import Vue from 'vue'; - -require('./help_state'); -require('./collapsed_state'); -require('./spent_only_pane'); -require('./no_tracking_pane'); -require('./estimate_only_pane'); -require('./comparison_pane'); - -(() => { - Vue.component('issuable-time-tracker', { - name: 'issuable-time-tracker', - props: [ - 'time_estimate', - 'time_spent', - 'human_time_estimate', - 'human_time_spent', - 'docsUrl', - ], - data() { - return { - showHelp: false, - }; - }, - computed: { - timeSpent() { - return this.time_spent; - }, - timeEstimate() { - return this.time_estimate; - }, - timeEstimateHumanReadable() { - return this.human_time_estimate; - }, - timeSpentHumanReadable() { - return this.human_time_spent; - }, - hasTimeSpent() { - return !!this.timeSpent; - }, - hasTimeEstimate() { - return !!this.timeEstimate; - }, - showComparisonState() { - return this.hasTimeEstimate && this.hasTimeSpent; - }, - showEstimateOnlyState() { - return this.hasTimeEstimate && !this.hasTimeSpent; - }, - showSpentOnlyState() { - return this.hasTimeSpent && !this.hasTimeEstimate; - }, - showNoTimeTrackingState() { - return !this.hasTimeEstimate && !this.hasTimeSpent; - }, - showHelpState() { - return !!this.showHelp; - }, - }, - methods: { - toggleHelpState(show) { - this.showHelp = show; - }, - }, - template: ` - <div class='time_tracker time-tracking-component-wrap' v-cloak> - <time-tracking-collapsed-state - :show-comparison-state='showComparisonState' - :show-help-state='showHelpState' - :show-spent-only-state='showSpentOnlyState' - :show-estimate-only-state='showEstimateOnlyState' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-collapsed-state> - <div class='title hide-collapsed'> - Time tracking - <div class='help-button pull-right' - v-if='!showHelpState' - @click='toggleHelpState(true)'> - <i class='fa fa-question-circle' aria-hidden='true'></i> - </div> - <div class='close-help-button pull-right' - v-if='showHelpState' - @click='toggleHelpState(false)'> - <i class='fa fa-close' aria-hidden='true'></i> - </div> - </div> - <div class='time-tracking-content hide-collapsed'> - <time-tracking-estimate-only-pane - v-if='showEstimateOnlyState' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-estimate-only-pane> - <time-tracking-spent-only-pane - v-if='showSpentOnlyState' - :time-spent-human-readable='timeSpentHumanReadable'> - </time-tracking-spent-only-pane> - <time-tracking-no-tracking-pane - v-if='showNoTimeTrackingState'> - </time-tracking-no-tracking-pane> - <time-tracking-comparison-pane - v-if='showComparisonState' - :time-estimate='timeEstimate' - :time-spent='timeSpent' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-comparison-pane> - <transition name='help-state-toggle'> - <time-tracking-help-state - v-if='showHelpState' - :docs-url='docsUrl'> - </time-tracking-help-state> - </transition> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js deleted file mode 100644 index 1689a69e1ed..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js +++ /dev/null @@ -1,66 +0,0 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -require('./components/time_tracker'); -require('../../smart_interval'); -require('../../subbable_resource'); - -Vue.use(VueResource); - -(() => { - /* This Vue instance represents what will become the parent instance for the - * sidebar. It will be responsible for managing `issuable` state and propagating - * changes to sidebar components. We will want to create a separate service to - * interface with the server at that point. - */ - - class IssuableTimeTracking { - constructor(issuableJSON) { - const parsedIssuable = JSON.parse(issuableJSON); - return this.initComponent(parsedIssuable); - } - - initComponent(parsedIssuable) { - this.parentInstance = new Vue({ - el: '#issuable-time-tracker', - data: { - issuable: parsedIssuable, - }, - methods: { - fetchIssuable() { - return gl.IssuableResource.get.call(gl.IssuableResource, { - type: 'GET', - url: gl.IssuableResource.endpoint, - }); - }, - updateState(data) { - this.issuable = data; - }, - subscribeToUpdates() { - gl.IssuableResource.subscribe(data => this.updateState(data)); - }, - listenForSlashCommands() { - $(document).on('ajax:success', '.gfm-form', (e, data) => { - const subscribedCommands = ['spend_time', 'time_estimate']; - const changedCommands = data.commands_changes - ? Object.keys(data.commands_changes) - : []; - if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { - this.fetchIssuable(); - } - }); - }, - }, - created() { - this.fetchIssuable(); - }, - mounted() { - this.subscribeToUpdates(); - this.listenForSlashCommands(); - }, - }); - } - } - - gl.IssuableTimeTracking = IssuableTimeTracking; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 011043e992f..694c6177a07 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ -/* global Flash */ + /* global Flash */ +import CreateMergeRequestDropdown from './create_merge_request_dropdown'; require('./flash'); require('~/lib/utils/text_utility'); @@ -18,48 +19,49 @@ class Issue { document.querySelector('#task_status_short').innerText = result.task_status_short; } }); - Issue.initIssueBtnEventListeners(); + this.initIssueBtnEventListeners(); } Issue.$btnNewBranch = $('#new-branch'); + Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); Issue.initMergeRequests(); Issue.initRelatedBranches(); - Issue.initCanCreateBranch(); + + if (Issue.createMrDropdownWrap) { + this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap); + } } - static initIssueBtnEventListeners() { + initIssueBtnEventListeners() { const issueFailMessage = 'Unable to update this issue at this time.'; - const closeButtons = $('a.btn-close'); const isClosedBadge = $('div.status-box-closed'); const isOpenBadge = $('div.status-box-open'); const projectIssuesCounter = $('.issue_counter'); const reopenButtons = $('a.btn-reopen'); - return closeButtons.add(reopenButtons).on('click', function(e) { - var $this, shouldSubmit, url; + return closeButtons.add(reopenButtons).on('click', (e) => { + var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); - $this = $(this); - shouldSubmit = $this.hasClass('btn-comment'); + $button = $(e.currentTarget); + shouldSubmit = $button.hasClass('btn-comment'); if (shouldSubmit) { - Issue.submitNoteForm($this.closest('form')); + Issue.submitNoteForm($button.closest('form')); } - $this.prop('disabled', true); - Issue.setNewBranchButtonState(true, null); - url = $this.attr('href'); + $button.prop('disabled', true); + url = $button.attr('href'); return $.ajax({ type: 'PUT', url: url - }).fail(function(jqXHR, textStatus, errorThrown) { - new Flash(issueFailMessage); - Issue.initCanCreateBranch(); - }).done(function(data, textStatus, jqXHR) { + }) + .fail(() => new Flash(issueFailMessage)) + .done((data) => { if ('id' in data) { $(document).trigger('issuable:change'); - const isClosed = $this.hasClass('btn-close'); + const isClosed = $button.hasClass('btn-close'); closeButtons.toggleClass('hidden', isClosed); reopenButtons.toggleClass('hidden', !isClosed); isClosedBadge.toggleClass('hidden', !isClosed); @@ -68,12 +70,21 @@ class Issue { let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues)); + + if (this.createMergeRequestDropdown) { + if (isClosed) { + this.createMergeRequestDropdown.unavailable(); + this.createMergeRequestDropdown.disable(); + } else { + // We should check in case a branch was created in another tab + this.createMergeRequestDropdown.checkAbilityToCreateBranch(); + } + } } else { new Flash(issueFailMessage); } - $this.prop('disabled', false); - Issue.initCanCreateBranch(); + $button.prop('disabled', false); }); }); } @@ -109,29 +120,6 @@ class Issue { } }); } - - static initCanCreateBranch() { - // If the user doesn't have the required permissions the container isn't - // rendered at all. - if (Issue.$btnNewBranch.length === 0) { - return; - } - return $.getJSON(Issue.$btnNewBranch.data('path')).fail(function() { - Issue.setNewBranchButtonState(false, false); - new Flash('Failed to check if a new branch can be created.'); - }).done(function(data) { - Issue.setNewBranchButtonState(false, data.can_create_branch); - }); - } - - static setNewBranchButtonState(isPending, canCreate) { - if (Issue.$btnNewBranch.length === 0) { - return; - } - - Issue.$btnNewBranch.find('.available').toggle(!isPending && canCreate); - Issue.$btnNewBranch.find('.unavailable').toggle(!isPending && !canCreate); - } } export default Issue; diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js new file mode 100644 index 00000000000..0740a9f559c --- /dev/null +++ b/app/assets/javascripts/issue_show/actions/tasks.js @@ -0,0 +1,27 @@ +export default (newStateData, tasks) => { + const $tasks = $('#task_status'); + const $tasksShort = $('#task_status_short'); + const $issueableHeader = $('.issuable-header'); + const tasksStates = { newState: null, currentState: null }; + + if ($tasks.length === 0) { + if (!(newStateData.task_status.indexOf('0 of 0') === 0)) { + $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); + } else { + $issueableHeader.append('<span id="task_status"></span>'); + } + } else { + tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0; + tasksStates.currentState = tasks.indexOf('0 of 0') === 0; + } + + if ($tasks.length !== 0 && !tasksStates.newState) { + $tasks.text(newStateData.task_status); + $tasksShort.text(newStateData.task_status); + } else if (tasksStates.currentState) { + $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); + } else if (tasksStates.newState) { + $tasks.remove(); + $tasksShort.remove(); + } +}; diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 4d491e70d83..eb20a597bb5 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,16 +1,16 @@ import Vue from 'vue'; -import IssueTitle from './issue_title.vue'; +import IssueTitle from './issue_title_description.vue'; import '../vue_shared/vue_resource_interceptor'; (() => { const issueTitleData = document.querySelector('.issue-title-data').dataset; - const { initialTitle, endpoint } = issueTitleData; + const { canUpdateTasksClass, endpoint } = issueTitleData; const vm = new Vue({ el: '.issue-title-entrypoint', render: createElement => createElement(IssueTitle, { props: { - initialTitle, + canUpdateTasksClass, endpoint, }, }), diff --git a/app/assets/javascripts/issue_show/issue_title.vue b/app/assets/javascripts/issue_show/issue_title.vue deleted file mode 100644 index 00b0e56030a..00000000000 --- a/app/assets/javascripts/issue_show/issue_title.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -import Visibility from 'visibilityjs'; -import Poll from './../lib/utils/poll'; -import Service from './services/index'; - -export default { - props: { - initialTitle: { required: true, type: String }, - endpoint: { required: true, type: String }, - }, - data() { - const resource = new Service(this.$http, this.endpoint); - - const poll = new Poll({ - resource, - method: 'getTitle', - successCallback: (res) => { - this.renderResponse(res); - }, - errorCallback: (err) => { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.error('ISSUE SHOW TITLE REALTIME ERROR', err); - } else { - throw new Error(err); - } - }, - }); - - return { - poll, - timeoutId: null, - title: this.initialTitle, - }; - }, - methods: { - renderResponse(res) { - const body = JSON.parse(res.body); - this.triggerAnimation(body); - }, - triggerAnimation(body) { - const { title } = body; - - /** - * since opacity is changed, even if there is no diff for Vue to update - * we must check the title even on a 304 to ensure no visual change - */ - if (this.title === title) return; - - this.$el.style.opacity = 0; - - this.timeoutId = setTimeout(() => { - this.title = title; - - this.$el.style.transition = 'opacity 0.2s ease'; - this.$el.style.opacity = 1; - - clearTimeout(this.timeoutId); - }, 100); - }, - }, - created() { - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - }, -}; -</script> - -<template> - <h2 class="title" v-html="title"></h2> -</template> diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue new file mode 100644 index 00000000000..dc3ba2550c5 --- /dev/null +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -0,0 +1,180 @@ +<script> +import Visibility from 'visibilityjs'; +import Poll from './../lib/utils/poll'; +import Service from './services/index'; +import tasks from './actions/tasks'; + +export default { + props: { + endpoint: { + required: true, + type: String, + }, + canUpdateTasksClass: { + required: true, + type: String, + }, + }, + data() { + const resource = new Service(this.$http, this.endpoint); + + const poll = new Poll({ + resource, + method: 'getTitle', + successCallback: (res) => { + this.renderResponse(res); + }, + errorCallback: (err) => { + throw new Error(err); + }, + }); + + return { + poll, + apiData: {}, + tasks: '0 of 0', + title: null, + titleText: '', + titleFlag: { + pre: true, + pulse: false, + }, + description: null, + descriptionText: '', + descriptionChange: false, + descriptionFlag: { + pre: true, + pulse: false, + }, + timeAgoEl: $('.issue_edited_ago'), + titleEl: document.querySelector('title'), + }; + }, + methods: { + updateFlag(key, toggle) { + this[key].pre = toggle; + this[key].pulse = !toggle; + }, + renderResponse(res) { + this.apiData = res.json(); + this.triggerAnimation(); + }, + updateTaskHTML() { + tasks(this.apiData, this.tasks); + }, + elementsToVisualize(noTitleChange, noDescriptionChange) { + if (!noTitleChange) { + this.titleText = this.apiData.title_text; + this.updateFlag('titleFlag', true); + } + + if (!noDescriptionChange) { + // only change to true when we need to bind TaskLists the html of description + this.descriptionChange = true; + this.updateTaskHTML(); + this.tasks = this.apiData.task_status; + this.updateFlag('descriptionFlag', true); + } + }, + setTabTitle() { + const currentTabTitleScope = this.titleEl.innerText.split('·'); + currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `; + this.titleEl.innerText = currentTabTitleScope.join('·'); + }, + animate(title, description) { + this.title = title; + this.description = description; + this.setTabTitle(); + + this.$nextTick(() => { + this.updateFlag('titleFlag', false); + this.updateFlag('descriptionFlag', false); + }); + }, + triggerAnimation() { + // always reset to false before checking the change + this.descriptionChange = false; + + const { title, description } = this.apiData; + this.descriptionText = this.apiData.description_text; + + const noTitleChange = this.title === title; + const noDescriptionChange = this.description === description; + + /** + * since opacity is changed, even if there is no diff for Vue to update + * we must check the title/description even on a 304 to ensure no visual change + */ + if (noTitleChange && noDescriptionChange) return; + + this.elementsToVisualize(noTitleChange, noDescriptionChange); + this.animate(title, description); + }, + updateEditedTimeAgo() { + const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); + this.timeAgoEl.attr('datetime', this.apiData.updated_at); + this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle'); + }, + }, + created() { + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + }, + updated() { + // if new html is injected (description changed) - bind TaskList and call renderGFM + if (this.descriptionChange) { + this.updateEditedTimeAgo(); + + $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); + + const tl = new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); + + return tl && null; + } + + return null; + }, +}; +</script> + +<template> + <div> + <h2 + class="title" + :class="{ 'issue-realtime-pre-pulse': titleFlag.pre, 'issue-realtime-trigger-pulse': titleFlag.pulse }" + ref="issue-title" + v-html="title" + > + </h2> + <div + class="description is-task-list-enabled" + :class="canUpdateTasksClass" + v-if="description" + > + <div + class="wiki" + :class="{ 'issue-realtime-pre-pulse': descriptionFlag.pre, 'issue-realtime-trigger-pulse': descriptionFlag.pulse }" + v-html="description" + ref="issue-content-container-gfm-entry" + > + </div> + <textarea + class="hidden js-task-list-field" + v-if="descriptionText" + >{{descriptionText}}</textarea> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index b2cfd3ef2a3..56cb536dcde 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js index e0ebd36a65c..fee3429e2b8 100644 --- a/app/assets/javascripts/issues_bulk_assignment.js +++ b/app/assets/javascripts/issues_bulk_assignment.js @@ -88,7 +88,10 @@ const formData = { update: { state_event: this.form.find('input[name="update[state_event]"]').val(), + // For Merge Requests assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + // For Issues + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 9a60f5464df..ac5ce84e31b 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -330,7 +330,10 @@ }, multiSelect: $dropdown.hasClass('js-multiselect'), vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(label, $el, e, isMarking) { + clicked: function(options) { + const { $el, e, isMarking } = options; + const label = options.selectedObj; + var isIssueIndex, isMRIndex, page, boardsModel; var fadeOutLoader = () => { $loading.fadeOut(); @@ -352,7 +355,7 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { _this.enableBulkLabelDropdown(); - _this.setDropdownData($dropdown, isMarking, this.id(label)); + _this.setDropdownData($dropdown, isMarking, label.id); return; } diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js new file mode 100644 index 00000000000..1d18992af63 --- /dev/null +++ b/app/assets/javascripts/lib/utils/accessor.js @@ -0,0 +1,47 @@ +function isPropertyAccessSafe(base, property) { + let safe; + + try { + safe = !!base[property]; + } catch (error) { + safe = false; + } + + return safe; +} + +function isFunctionCallSafe(base, functionName, ...args) { + let safe = true; + + try { + base[functionName](...args); + } catch (error) { + safe = false; + } + + return safe; +} + +function isLocalStorageAccessSafe() { + let safe; + + const TEST_KEY = 'isLocalStorageAccessSafe'; + const TEST_VALUE = 'true'; + + safe = isPropertyAccessSafe(window, 'localStorage'); + if (!safe) return safe; + + safe = isFunctionCallSafe(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE); + + if (safe) window.localStorage.removeItem(TEST_KEY); + + return safe; +} + +const AccessorUtilities = { + isPropertyAccessSafe, + isFunctionCallSafe, + isLocalStorageAccessSafe, +}; + +export default AccessorUtilities; diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js new file mode 100644 index 00000000000..d99eefb5089 --- /dev/null +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -0,0 +1,32 @@ +const AjaxCache = { + internalStorage: { }, + get(endpoint) { + return this.internalStorage[endpoint]; + }, + hasData(endpoint) { + return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint); + }, + purge(endpoint) { + delete this.internalStorage[endpoint]; + }, + retrieve(endpoint) { + if (AjaxCache.hasData(endpoint)) { + return Promise.resolve(AjaxCache.get(endpoint)); + } + + return new Promise((resolve, reject) => { + $.ajax(endpoint) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${endpoint}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }) + .then((data) => { this.internalStorage[endpoint] = data; }) + .then(() => AjaxCache.get(endpoint)); + }, +}; + +export default AjaxCache; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8058672eaa9..2f682fbd2fb 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -35,6 +35,14 @@ }); }; + w.gl.utils.ajaxPost = function(url, data) { + return $.ajax({ + type: 'POST', + url: url, + data: data, + }); + }; + w.gl.utils.extractLast = function(term) { return this.split(term).pop(); }; diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js new file mode 100644 index 00000000000..e96090da80e --- /dev/null +++ b/app/assets/javascripts/locale/de/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js new file mode 100644 index 00000000000..ade9b667b3c --- /dev/null +++ b/app/assets/javascripts/locale/en/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js new file mode 100644 index 00000000000..3dafa21f235 --- /dev/null +++ b/app/assets/javascripts/locale/es/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 19:24-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d día","Últimos %d días"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["día","días"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js new file mode 100644 index 00000000000..7ba676d6d20 --- /dev/null +++ b/app/assets/javascripts/locale/index.js @@ -0,0 +1,70 @@ +import Jed from 'jed'; + +/** + This is required to require all the translation folders in the current directory + this saves us having to do this manually & keep up to date with new languages +**/ +function requireAll(requireContext) { return requireContext.keys().map(requireContext); } + +const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/)); +const locales = allLocales.reduce((d, obj) => { + const data = d; + const localeKey = Object.keys(obj)[0]; + + data[localeKey] = obj[localeKey]; + + return data; +}, {}); + +let lang = document.querySelector('html').getAttribute('lang') || 'en'; +lang = lang.replace(/-/g, '_'); + +const locale = new Jed(locales[lang]); + +/** + Translates `text` + + @param text The text to be translated + @returns {String} The translated text +**/ +const gettext = locale.gettext.bind(locale); + +/** + Translate the text with a number + if the number is more than 1 it will use the `pluralText` translation. + This method allows for contexts, see below re. contexts + + @param text Singular text to translate (eg. '%d day') + @param pluralText Plural text to translate (eg. '%d days') + @param count Number to decide which translation to use (eg. 2) + @returns {String} Translated text with the number replaced (eg. '2 days') +**/ +const ngettext = (text, pluralText, count) => { + const translated = locale.ngettext(text, pluralText, count).replace(/%d/g, count).split('|'); + + return translated[translated.length - 1]; +}; + +/** + Translate context based text + Either pass in the context translation like `Context|Text to translate` + or allow for dynamic text by doing passing in the context first & then the text to translate + + @param keyOrContext Can be either the key to translate including the context + (eg. 'Context|Text') or just the context for the translation + (eg. 'Context') + @param key Is the dynamic variable you want to be translated + @returns {String} Translated context based text +**/ +const pgettext = (keyOrContext, key) => { + const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext; + const translated = gettext(normalizedKey).split('|'); + + return translated[translated.length - 1]; +}; + +export { lang }; +export { gettext as __ }; +export { ngettext as n__ }; +export { pgettext as s__ }; +export default locale; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index be3c2c9fbb1..1b0d5fc92e3 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -158,7 +158,6 @@ import './single_file_diff'; import './smart_interval'; import './snippets_list'; import './star'; -import './subbable_resource'; import './subscription'; import './subscription_select'; import './syntax_highlight'; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index e3f367a11eb..8291b8c4a70 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -31,8 +31,8 @@ toggleLabel(selected, $el) { return $el.text(); }, - clicked: (selected, $link) => { - this.formSubmit(null, $link); + clicked: (options) => { + this.formSubmit(null, options.$el); }, }); }); diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 42ecf0d6cb2..6f6ae9bde92 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -291,7 +291,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; MergeRequestWidget.prototype.updateCommitUrls = function(id) { const commitsUrl = this.opts.commits_path; - $('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/')); + $('.js-commit-link').text(id).attr('href', [commitsUrl, id].join('/')); }; MergeRequestWidget.prototype.initMiniPipelineGraph = function() { diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 38c673e8907..841b24a60a3 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -19,12 +19,10 @@ }); }; - Milestone.sortIssues = function(data) { - var sort_issues_url; - sort_issues_url = location.href + "/sort_issues"; + Milestone.sortIssues = function(url, data) { return $.ajax({ type: "PUT", - url: sort_issues_url, + url, data: data, success: function(_data) { return Milestone.successCallback(_data); @@ -36,12 +34,10 @@ }); }; - Milestone.sortMergeRequests = function(data) { - var sort_mr_url; - sort_mr_url = location.href + "/sort_merge_requests"; + Milestone.sortMergeRequests = function(url, data) { return $.ajax({ type: "PUT", - url: sort_mr_url, + url, data: data, success: function(_data) { return Milestone.successCallback(_data); @@ -81,42 +77,55 @@ }; function Milestone() { - var oldMouseStart; + this.issuesSortEndpoint = $('#tab-issues').data('sort-endpoint'); + this.mergeRequestsSortEndpoint = $('#tab-merge-requests').data('sort-endpoint'); + this.bindIssuesSorting(); - this.bindMergeRequestSorting(); this.bindTabsSwitching(); + + // Load merge request tab if it is active + // merge request tab is active based on different conditions in the backend + this.loadTab($('.js-milestone-tabs .active a')); + + this.loadInitialTab(); } Milestone.prototype.bindIssuesSorting = function() { + if (!this.issuesSortEndpoint) return; + $('#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed').each(function (i, el) { this.createSortable(el, { group: 'issue-list', listEls: $('.issues-sortable-list'), fieldName: 'issue', - sortCallback: Milestone.sortIssues, + sortCallback: (data) => { + Milestone.sortIssues(this.issuesSortEndpoint, data); + }, updateCallback: Milestone.updateIssue, }); }.bind(this)); }; Milestone.prototype.bindTabsSwitching = function() { - return $('a[data-toggle="tab"]').on('show.bs.tab', function(e) { - var currentTabClass, previousTabClass; - currentTabClass = $(e.target).data('show'); - previousTabClass = $(e.relatedTarget).data('show'); - $(previousTabClass).hide(); - $(currentTabClass).removeClass('hidden'); - return $(currentTabClass).show(); + return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => { + const $target = $(e.target); + + location.hash = $target.attr('href'); + this.loadTab($target); }); }; Milestone.prototype.bindMergeRequestSorting = function() { + if (!this.mergeRequestsSortEndpoint) return; + $("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").each(function (i, el) { this.createSortable(el, { group: 'merge-request-list', listEls: $(".merge_requests-sortable-list:not(#merge_requests-list-merged)"), fieldName: 'merge_request', - sortCallback: Milestone.sortMergeRequests, + sortCallback: (data) => { + Milestone.sortMergeRequests(this.mergeRequestsSortEndpoint, data); + }, updateCallback: Milestone.updateMergeRequest, }); }.bind(this)); @@ -169,6 +178,35 @@ }); }; + Milestone.prototype.loadInitialTab = function() { + const $target = $(`.js-milestone-tabs a[href="${location.hash}"]`); + + if ($target.length) { + $target.tab('show'); + } + }; + + Milestone.prototype.loadTab = function($target) { + const endpoint = $target.data('endpoint'); + const tabElId = $target.attr('href'); + + if (endpoint && !$target.hasClass('is-loaded')) { + $.ajax({ + url: endpoint, + dataType: 'JSON', + }) + .fail(() => new Flash('Error loading milestone tab')) + .done((data) => { + $(tabElId).html(data.html); + $target.addClass('is-loaded'); + + if (tabElId === '#tab-merge-requests') { + this.bindMergeRequestSorting(); + } + }); + } + }; + return Milestone; })(); }).call(window); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index bebd0aa357e..11e68c0a3be 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -121,7 +121,10 @@ return $value.css('display', ''); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(selected, $el, e) { + clicked: function(options) { + const { $el, e } = options; + let selected = options.selectedObj; + var data, isIssueIndex, isMRIndex, page, boardsStore; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js new file mode 100644 index 00000000000..c3a8da52404 --- /dev/null +++ b/app/assets/javascripts/monitoring/constants.js @@ -0,0 +1,4 @@ +import d3 from 'd3'; + +export const dateFormat = d3.time.format('%b %d, %Y'); +export const timeFormat = d3.time.format('%H:%M%p'); diff --git a/app/assets/javascripts/monitoring/deployments.js b/app/assets/javascripts/monitoring/deployments.js new file mode 100644 index 00000000000..fc92ab61b31 --- /dev/null +++ b/app/assets/javascripts/monitoring/deployments.js @@ -0,0 +1,211 @@ +/* global Flash */ +import d3 from 'd3'; +import { + dateFormat, + timeFormat, +} from './constants'; + +export default class Deployments { + constructor(width, height) { + this.width = width; + this.height = height; + + this.endpoint = document.getElementById('js-metrics').dataset.deploymentEndpoint; + + this.createGradientDef(); + } + + init(chartData) { + this.chartData = chartData; + + this.x = d3.time.scale().range([0, this.width]); + this.x.domain(d3.extent(this.chartData, d => d.time)); + + this.charts = d3.selectAll('.prometheus-graph'); + + this.getData(); + } + + getData() { + $.ajax({ + url: this.endpoint, + dataType: 'JSON', + }) + .fail(() => new Flash('Error getting deployment information.')) + .done((data) => { + this.data = data.deployments.reduce((deploymentDataArray, deployment) => { + const time = new Date(deployment.created_at); + const xPos = Math.floor(this.x(time)); + + time.setSeconds(this.chartData[0].time.getSeconds()); + + if (xPos >= 0) { + deploymentDataArray.push({ + id: deployment.id, + time, + sha: deployment.sha, + tag: deployment.tag, + ref: deployment.ref.name, + xPos, + }); + } + + return deploymentDataArray; + }, []); + + this.plotData(); + }); + } + + plotData() { + this.charts.each((d, i) => { + const svg = d3.select(this.charts[0][i]); + const chart = svg.select('.graph-container'); + const key = svg.node().getAttribute('graph-type'); + + this.createLine(chart, key); + this.createDeployInfoBox(chart, key); + }); + } + + createGradientDef() { + const defs = d3.select('body') + .append('svg') + .attr({ + height: 0, + width: 0, + }) + .append('defs'); + + defs.append('linearGradient') + .attr({ + id: 'shadow-gradient', + }) + .append('stop') + .attr({ + offset: '0%', + 'stop-color': '#000', + 'stop-opacity': 0.4, + }) + .select(this.selectParentNode) + .append('stop') + .attr({ + offset: '100%', + 'stop-color': '#000', + 'stop-opacity': 0, + }); + } + + createLine(chart, key) { + chart.append('g') + .attr({ + class: 'deploy-info', + }) + .selectAll('.deploy-info') + .data(this.data) + .enter() + .append('g') + .attr({ + class: d => `deploy-info-${d.id}-${key}`, + transform: d => `translate(${Math.floor(d.xPos) + 1}, 0)`, + }) + .append('rect') + .attr({ + x: 1, + y: 0, + height: this.height + 1, + width: 3, + fill: 'url(#shadow-gradient)', + }) + .select(this.selectParentNode) + .append('line') + .attr({ + class: 'deployment-line', + x1: 0, + x2: 0, + y1: 0, + y2: this.height + 1, + }); + } + + createDeployInfoBox(chart, key) { + chart.selectAll('.deploy-info') + .selectAll('.js-deploy-info-box') + .data(this.data) + .enter() + .select(d => document.querySelector(`.deploy-info-${d.id}-${key}`)) + .append('svg') + .attr({ + class: 'js-deploy-info-box hidden', + x: 3, + y: 0, + width: 92, + height: 60, + }) + .append('rect') + .attr({ + class: 'rect-text-metric deploy-info-rect rect-metric', + x: 1, + y: 1, + rx: 2, + width: 90, + height: 58, + }) + .select(this.selectParentNode) + .append('g') + .attr({ + transform: 'translate(5, 2)', + }) + .append('text') + .attr({ + class: 'deploy-info-text text-metric-bold', + }) + .text(Deployments.refText) + .select(this.selectParentNode) + .append('text') + .attr({ + class: 'deploy-info-text', + y: 18, + }) + .text(d => dateFormat(d.time)) + .select(this.selectParentNode) + .append('text') + .attr({ + class: 'deploy-info-text text-metric-bold', + y: 38, + }) + .text(d => timeFormat(d.time)); + } + + static toggleDeployTextbox(deploy, key, showInfoBox) { + d3.selectAll(`.deploy-info-${deploy.id}-${key} .js-deploy-info-box`) + .classed('hidden', !showInfoBox); + } + + mouseOverDeployInfo(mouseXPos, key) { + if (!this.data) return false; + + let dataFound = false; + + this.data.forEach((d) => { + if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { + dataFound = d.xPos + 1; + + Deployments.toggleDeployTextbox(d, key, true); + } else { + Deployments.toggleDeployTextbox(d, key, false); + } + }); + + return dataFound; + } + + /* `this` is bound to the D3 node */ + selectParentNode() { + return this.parentNode; + } + + static refText(d) { + return d.tag ? d.ref : d.sha.slice(0, 6); + } +} diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index 78bb0e6fb47..6af88769129 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -3,16 +3,20 @@ import d3 from 'd3'; import statusCodes from '~/lib/utils/http_status'; -import { formatRelevantDigits } from '~/lib/utils/number_utils'; +import Deployments from './deployments'; +import '../lib/utils/common_utils'; +import { formatRelevantDigits } from '../lib/utils/number_utils'; import '../flash'; +import { + dateFormat, + timeFormat, +} from './constants'; const prometheusContainer = '.prometheus-container'; const prometheusParentGraphContainer = '.prometheus-graphs'; const prometheusGraphsContainer = '.prometheus-graph'; const prometheusStatesContainer = '.prometheus-state'; const metricsEndpoint = 'metrics.json'; -const timeFormat = d3.time.format('%H:%M'); -const dayFormat = d3.time.format('%b %e, %a'); const bisectDate = d3.bisector(d => d.time).left; const extraAddedWidthParent = 100; @@ -36,6 +40,7 @@ class PrometheusGraph { this.width = parentContainerWidth - this.margin.left - this.margin.right; this.height = this.originalHeight - this.margin.top - this.margin.bottom; this.backOffRequestCounter = 0; + this.deployments = new Deployments(this.width, this.height); this.configureGraph(); this.init(); } else { @@ -74,6 +79,12 @@ class PrometheusGraph { $(prometheusParentGraphContainer).show(); this.transformData(metricsResponse); this.createGraph(); + + const firstMetricData = this.graphSpecificProperties[ + Object.keys(this.graphSpecificProperties)[0] + ].data; + + this.deployments.init(firstMetricData); } }); } @@ -96,6 +107,7 @@ class PrometheusGraph { .attr('width', this.width + this.margin.left + this.margin.right) .attr('height', this.height + this.margin.bottom + this.margin.top) .append('g') + .attr('class', 'graph-container') .attr('transform', `translate(${this.margin.left},${this.margin.top})`); const axisLabelContainer = d3.select(prometheusGraphContainer) @@ -116,6 +128,7 @@ class PrometheusGraph { .scale(y) .ticks(this.commonGraphProperties.axis_no_ticks) .tickSize(-this.width) + .outerTickSize(0) .orient('left'); this.createAxisLabelContainers(axisLabelContainer, key); @@ -248,7 +261,8 @@ class PrometheusGraph { const d1 = currentGraphProps.data[overlayIndex]; const evalTime = timeValueOverlay - d0.time > d1.time - timeValueOverlay; const currentData = evalTime ? d1 : d0; - const currentTimeCoordinate = currentGraphProps.xScale(currentData.time); + const currentTimeCoordinate = Math.floor(currentGraphProps.xScale(currentData.time)); + const currentDeployXPos = this.deployments.mouseOverDeployInfo(currentXCoordinate, key); const currentPrometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; const maxValueFromData = d3.max(currentGraphProps.data.map(metricValue => metricValue.value)); const maxMetricValue = currentGraphProps.yScale(maxValueFromData); @@ -256,13 +270,12 @@ class PrometheusGraph { // Clear up all the pieces of the flag d3.selectAll(`${currentPrometheusGraphContainer} .selected-metric-line`).remove(); d3.selectAll(`${currentPrometheusGraphContainer} .circle-metric`).remove(); - d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric`).remove(); - d3.selectAll(`${currentPrometheusGraphContainer} .text-metric`).remove(); + d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric:not(.deploy-info-rect)`).remove(); const currentChart = d3.select(currentPrometheusGraphContainer).select('g'); currentChart.append('line') - .attr('class', 'selected-metric-line') .attr({ + class: `${currentDeployXPos ? 'hidden' : ''} selected-metric-line`, x1: currentTimeCoordinate, y1: currentGraphProps.yScale(0), x2: currentTimeCoordinate, @@ -272,33 +285,45 @@ class PrometheusGraph { currentChart.append('circle') .attr('class', 'circle-metric') .attr('fill', currentGraphProps.line_color) - .attr('cx', currentTimeCoordinate) + .attr('cx', currentDeployXPos || currentTimeCoordinate) .attr('cy', currentGraphProps.yScale(currentData.value)) .attr('r', this.commonGraphProperties.circle_radius_metric); + if (currentDeployXPos) return; + // The little box with text - const rectTextMetric = currentChart.append('g') - .attr('class', 'rect-text-metric') - .attr('translate', `(${currentTimeCoordinate}, ${currentGraphProps.yScale(currentData.value)})`); + const rectTextMetric = currentChart.append('svg') + .attr({ + class: 'rect-text-metric', + x: currentTimeCoordinate, + y: 0, + }); rectTextMetric.append('rect') - .attr('class', 'rect-metric') - .attr('x', currentTimeCoordinate + 10) - .attr('y', maxMetricValue) - .attr('width', this.commonGraphProperties.rect_text_width) - .attr('height', this.commonGraphProperties.rect_text_height); + .attr({ + class: 'rect-metric', + x: 4, + y: 1, + rx: 2, + width: this.commonGraphProperties.rect_text_width, + height: this.commonGraphProperties.rect_text_height, + }); rectTextMetric.append('text') - .attr('class', 'text-metric') - .attr('x', currentTimeCoordinate + 35) - .attr('y', maxMetricValue + 35) + .attr({ + class: 'text-metric text-metric-bold', + x: 8, + y: 35, + }) .text(timeFormat(currentData.time)); rectTextMetric.append('text') - .attr('class', 'text-metric-date') - .attr('x', currentTimeCoordinate + 15) - .attr('y', maxMetricValue + 15) - .text(dayFormat(currentData.time)); + .attr({ + class: 'text-metric-date', + x: 8, + y: 15, + }) + .text(dateFormat(currentData.time)); let currentMetricValue = formatRelevantDigits(currentData.value); if (key === 'cpu_values') { diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index b98e6121967..36bc1257cef 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -58,7 +58,8 @@ }); } - NamespaceSelect.prototype.onSelectItem = function(item, el, e) { + NamespaceSelect.prototype.onSelectItem = function(options) { + const { e } = options; return e.preventDefault(); }; diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 5828f460a23..67046d52a65 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -34,6 +34,7 @@ filterByText: true, remote: false, fieldName: $branchSelect.data('field-name'), + filterInput: 'input[type="search"]', selectable: true, isSelectable: function(branch, $el) { return !$el.hasClass('is-active'); @@ -50,6 +51,21 @@ } } }); + + const $dropdownContainer = $branchSelect.closest('.dropdown'); + const $fieldInput = $(`input[name="${$branchSelect.data('field-name')}"]`, $dropdownContainer); + const $filterInput = $('input[type="search"]', $dropdownContainer); + + $filterInput.on('keyup', (e) => { + const keyCode = e.keyCode || e.which; + if (keyCode !== 13) return; + + const text = $filterInput.val(); + $fieldInput.val(text); + $('.dropdown-toggle-text', $branchSelect).text(text); + + $dropdownContainer.removeClass('open'); + }); }; NewBranchForm.prototype.setupRestrictions = function() { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 974fb0d83da..55391ebc089 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -4,6 +4,7 @@ /* global ResolveService */ /* global mrRefreshWidgetUrl */ +import $ from 'jquery'; import Cookies from 'js-cookie'; import CommentTypeToggle from './comment_type_toggle'; @@ -16,17 +17,22 @@ require('vendor/jquery.caret'); // required by jquery.atwho require('vendor/jquery.atwho'); require('./task_list'); +const normalizeNewlines = function(str) { + return str.replace(/\r\n/g, '\n'); +}; + (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; this.Notes = (function() { const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + const REGEX_SLASH_COMMANDS = /\/\w+/g; Notes.interval = null; function Notes(notes_url, note_ids, last_fetched_at, view) { this.updateTargetButtons = bind(this.updateTargetButtons, this); - this.updateCloseButton = bind(this.updateCloseButton, this); + this.updateComment = bind(this.updateComment, this); this.visibilityChange = bind(this.visibilityChange, this); this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this); this.addDiffNote = bind(this.addDiffNote, this); @@ -42,13 +48,18 @@ require('./task_list'); this.refresh = bind(this.refresh, this); this.keydownNoteText = bind(this.keydownNoteText, this); this.toggleCommitList = bind(this.toggleCommitList, this); + this.postComment = bind(this.postComment, this); + this.notes_url = notes_url; this.note_ids = note_ids; + // Used to keep track of updated notes while people are editing things + this.updatedNotesTrackingMap = {}; this.last_fetched_at = last_fetched_at; this.noteable_url = document.URL; this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge")); this.basePollingInterval = 15000; this.maxPollingSteps = 4; + this.cleanBinding(); this.addBinding(); this.setPollingInterval(); @@ -73,28 +84,19 @@ require('./task_list'); }; Notes.prototype.addBinding = function() { - // add note to UI after creation - $(document).on("ajax:success", ".js-main-target-form", this.addNote); - $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); - // catch note ajax errors - $(document).on("ajax:error", ".js-main-target-form", this.addNoteError); - // change note in UI after update - $(document).on("ajax:success", "form.edit-note", this.updateNote); // Edit note link $(document).on("click", ".js-note-edit", this.showEditForm.bind(this)); $(document).on("click", ".note-edit-cancel", this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - $(document).on("click", ".js-comment-button", this.updateCloseButton); + $(document).on("click", ".js-comment-submit-button", this.postComment); + $(document).on("click", ".js-comment-save-button", this.updateComment); $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); // resolve a discussion - $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion); + $(document).on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) $(document).on("click", ".js-note-delete", this.removeNote); // delete note attachment $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); - // reset main target form after submit - $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); - $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); // reset main target form when clicking discard $(document).on("click", ".js-note-discard", this.resetMainTargetForm); // update the file name when an attachment is selected @@ -111,30 +113,33 @@ require('./task_list'); $(document).on("visibilitychange", this.visibilityChange); // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); + // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. + $(document).on("ajax:success", ".js-main-target-form", this.addNote); + $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); + $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); + $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; Notes.prototype.cleanBinding = function() { - $(document).off("ajax:success", ".js-main-target-form"); - $(document).off("ajax:success", ".js-discussion-note-form"); - $(document).off("ajax:success", "form.edit-note"); $(document).off("click", ".js-note-edit"); $(document).off("click", ".note-edit-cancel"); $(document).off("click", ".js-note-delete"); $(document).off("click", ".js-note-attachment-delete"); - $(document).off("ajax:complete", ".js-main-target-form"); - $(document).off("ajax:success", ".js-main-target-form"); $(document).off("click", ".js-discussion-reply-button"); $(document).off("click", ".js-add-diff-note-button"); $(document).off("visibilitychange"); - $(document).off("keyup", ".js-note-text"); + $(document).off("keyup input", ".js-note-text"); $(document).off("click", ".js-note-target-reopen"); $(document).off("click", ".js-note-target-close"); $(document).off("click", ".js-note-discard"); $(document).off("keydown", ".js-note-text"); $(document).off('click', '.js-comment-resolve-button'); $(document).off("click", '.system-note-commit-list-toggler'); + $(document).off("ajax:success", ".js-main-target-form"); + $(document).off("ajax:success", ".js-discussion-note-form"); + $(document).off("ajax:complete", ".js-main-target-form"); }; Notes.initCommentTypeToggle = function (form) { @@ -267,20 +272,16 @@ require('./task_list'); return this.initRefresh(); }; - Notes.prototype.handleCreateChanges = function(note) { + Notes.prototype.handleSlashCommands = function(noteEntity) { var votesBlock; - if (typeof note === 'undefined') { - return; - } - - if (note.commands_changes) { - if ('merge' in note.commands_changes) { + if (noteEntity.commands_changes) { + if ('merge' in noteEntity.commands_changes) { $.get(mrRefreshWidgetUrl); } - if ('emoji_award' in note.commands_changes) { + if ('emoji_award' in noteEntity.commands_changes) { votesBlock = $('.js-awards-block').eq(0); - gl.awardsHandler.addAwardToEmojiBar(votesBlock, note.commands_changes.emoji_award); + gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); return gl.awardsHandler.scrollToAwards(); } } @@ -292,41 +293,76 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderNote = function(note, $form) { - var $notesList; - if (note.discussion_html != null) { - return this.renderDiscussionNote(note, $form); + Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) { + if (noteEntity.discussion_html != null) { + return this.renderDiscussionNote(noteEntity, $form); } - if (!note.valid) { - if (note.errors.commands_only) { - new Flash(note.errors.commands_only, 'notice', this.parentTimeline); + if (!noteEntity.valid) { + if (noteEntity.errors.commands_only) { + new Flash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); this.refresh(); } return; } - if (this.isNewNote(note)) { - this.note_ids.push(note.id); + const $note = $notesList.find(`#note_${noteEntity.id}`); + if (this.isNewNote(noteEntity)) { + this.note_ids.push(noteEntity.id); - $notesList = window.$('ul.main-notes-list'); - Notes.animateAppendNote(note.html, $notesList); + const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); // Update datetime format on the recent note - gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); + gl.utils.localTimeAgo($newNote.find('.js-timeago'), false); this.collapseLongCommitList(); this.taskList.init(); this.refresh(); return this.updateNotesCount(1); } + // The server can send the same update multiple times so we need to make sure to only update once per actual update. + else if (this.isUpdatedNote(noteEntity, $note)) { + const isEditing = $note.hasClass('is-editing'); + const initialContent = normalizeNewlines( + $note.find('.original-note-content').text().trim() + ); + const $textarea = $note.find('.js-note-text'); + const currentContent = $textarea.val(); + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteNote = normalizeNewlines(noteEntity.note); + const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote; + + if (isEditing && isTextareaUntouched) { + $textarea.val(noteEntity.note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; + } + else if (isEditing && !isTextareaUntouched) { + this.putConflictEditWarningInPlace(noteEntity, $note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; + } + else { + const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); + + // Update datetime format on the recent note + gl.utils.localTimeAgo($updatedNote.find('.js-timeago'), false); + } + } }; /* Check if note does not exists on page */ - Notes.prototype.isNewNote = function(note) { - return $.inArray(note.id, this.note_ids) === -1; + Notes.prototype.isNewNote = function(noteEntity) { + return $.inArray(noteEntity.id, this.note_ids) === -1; + }; + + Notes.prototype.isUpdatedNote = function(noteEntity, $note) { + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteNote = normalizeNewlines(noteEntity.note); + const currentNoteText = normalizeNewlines( + $note.find('.original-note-content').text().trim() + ); + return sanitizedNoteNote !== currentNoteText; }; Notes.prototype.isParallelView = function() { @@ -339,31 +375,31 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderDiscussionNote = function(note, $form) { + Notes.prototype.renderDiscussionNote = function(noteEntity, $form) { var discussionContainer, form, row, lineType, diffAvatarContainer; - if (!this.isNewNote(note)) { + if (!this.isNewNote(noteEntity)) { return; } - this.note_ids.push(note.id); - form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.discussion_id + "']"); + this.note_ids.push(noteEntity.id); + form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']"); row = form.closest("tr"); lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? - discussionContainer = window.$(`.notes[data-discussion-id="${note.discussion_id}"]`); + discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); if (!discussionContainer.length) { discussionContainer = form.closest('.discussion').find('.notes'); } if (discussionContainer.length === 0) { - if (note.diff_discussion_html) { - var $discussion = $(note.diff_discussion_html).renderGFM(); + if (noteEntity.diff_discussion_html) { + var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) { // insert the note and the reply button after the temp row row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]'); + var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]'); var contentContainerClass = '.' + $notes.closest('.notes_content') .attr('class') .split(' ') @@ -373,17 +409,18 @@ require('./task_list'); } } // Init discussion on 'Discussion' page if it is merge request page - if (window.$('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) { - Notes.animateAppendNote(note.discussion_html, window.$('ul.main-notes-list')); + const page = $('body').attr('data-page'); + if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } } else { // append new note to all matching discussions - Notes.animateAppendNote(note.html, discussionContainer); + Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { gl.diffNotesCompileComponents(); - this.renderDiscussionAvatar(diffAvatarContainer, note); + this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); } gl.utils.localTimeAgo($('.js-timeago'), false); @@ -397,13 +434,13 @@ require('./task_list'); .get(0); }; - Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, note) { + Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, noteEntity) { var commentButton = diffAvatarContainer.find('.js-add-diff-note-button'); var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); if (!avatarHolder.length) { avatarHolder = document.createElement('diff-note-avatars'); - avatarHolder.setAttribute('discussion-id', note.discussion_id); + avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id); diffAvatarContainer.append(avatarHolder); @@ -511,24 +548,29 @@ require('./task_list'); Adds new note to list. */ - Notes.prototype.addNote = function(xhr, note, status) { - this.handleCreateChanges(note); + Notes.prototype.addNote = function($form, note) { return this.renderNote(note); }; - Notes.prototype.addNoteError = function(xhr, note, status) { - return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline); + Notes.prototype.addNoteError = ($form) => { + let formParentTimeline; + if ($form.hasClass('js-main-target-form')) { + formParentTimeline = $form.parents('.timeline'); + } else if ($form.hasClass('js-discussion-note-form')) { + formParentTimeline = $form.closest('.discussion-notes').find('.notes'); + } + return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); }; + Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.'); + /* Called in response to the new note form being submitted Adds new note to list. */ - Notes.prototype.addDiscussionNote = function(xhr, note, status) { - var $form = $(xhr.target); - + Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) { if ($form.attr('data-resolve-all') != null) { var projectPath = $form.data('project-path'); var discussionId = $form.data('discussion-id'); @@ -541,7 +583,9 @@ require('./task_list'); this.renderNote(note, $form); // cleanup after successfully creating a diff/discussion note - this.removeDiscussionNoteForm($form); + if (isNewDiffComment) { + this.removeDiscussionNoteForm($form); + } }; /* @@ -550,18 +594,19 @@ require('./task_list'); Updates the current note field. */ - Notes.prototype.updateNote = function(_xhr, note, _status) { - var $html, $note_li; + Notes.prototype.updateNote = function(_xhr, noteEntity, _status) { + var $noteEntityEl, $note_li; // Convert returned HTML to a jQuery object so we can modify it further - $html = $(note.html); + $noteEntityEl = $(noteEntity.html); + $noteEntityEl.addClass('fade-in-full'); this.revertNoteEditForm(); - gl.utils.localTimeAgo($('.js-timeago', $html)); - $html.renderGFM(); - $html.find('.js-task-list-container').taskList('enable'); + gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl)); + $noteEntityEl.renderGFM(); + $noteEntityEl.find('.js-task-list-container').taskList('enable'); // Find the note's `li` element by ID and replace it with the updated HTML - $note_li = $('.note-row-' + note.id); + $note_li = $('.note-row-' + noteEntity.id); - $note_li.replaceWith($html); + $note_li.replaceWith($noteEntityEl); if (typeof gl.diffNotesCompileComponents !== 'undefined') { gl.diffNotesCompileComponents(); @@ -570,7 +615,7 @@ require('./task_list'); Notes.prototype.checkContentToAllowEditing = function($el) { var initialContent = $el.find('.original-note-content').text().trim(); - var currentContent = $el.find('.note-textarea').val(); + var currentContent = $el.find('.js-note-text').val(); var isAllowed = true; if (currentContent === initialContent) { @@ -584,7 +629,7 @@ require('./task_list'); gl.utils.scrollToElement($el); } - $el.find('.js-edit-warning').show(); + $el.find('.js-finish-edit-warning').show(); isAllowed = false; } @@ -603,7 +648,7 @@ require('./task_list'); var $target = $(e.target); var $editForm = $(this.getEditFormSelector($target)); var $note = $target.closest('.note'); - var $currentlyEditing = $('.note.is-editting:visible'); + var $currentlyEditing = $('.note.is-editing:visible'); if ($currentlyEditing.length) { var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); @@ -615,7 +660,7 @@ require('./task_list'); $note.find('.js-note-attachment-delete').show(); $editForm.addClass('current-note-edit-form'); - $note.addClass('is-editting'); + $note.addClass('is-editing'); this.putEditFormInPlace($target); }; @@ -627,21 +672,34 @@ require('./task_list'); Notes.prototype.cancelEdit = function(e) { e.preventDefault(); - var $target = $(e.target); - var note = $target.closest('.note'); - note.find('.js-edit-warning').hide(); + const $target = $(e.target); + const $note = $target.closest('.note'); + const noteId = $note.attr('data-note-id'); + this.revertNoteEditForm($target); - return this.removeNoteEditForm(note); + + if (this.updatedNotesTrackingMap[noteId]) { + const $newNote = $(this.updatedNotesTrackingMap[noteId].html); + $note.replaceWith($newNote); + this.updatedNotesTrackingMap[noteId] = null; + + // Update datetime format on the recent note + gl.utils.localTimeAgo($newNote.find('.js-timeago'), false); + } + else { + $note.find('.js-finish-edit-warning').hide(); + this.removeNoteEditForm($note); + } }; Notes.prototype.revertNoteEditForm = function($target) { - $target = $target || $('.note.is-editting:visible'); + $target = $target || $('.note.is-editing:visible'); var selector = this.getEditFormSelector($target); var $editForm = $(selector); $editForm.insertBefore('.notes-form'); - $editForm.find('.js-comment-button').enable(); - $editForm.find('.js-edit-warning').hide(); + $editForm.find('.js-comment-save-button').enable(); + $editForm.find('.js-finish-edit-warning').hide(); }; Notes.prototype.getEditFormSelector = function($el) { @@ -654,11 +712,11 @@ require('./task_list'); return selector; }; - Notes.prototype.removeNoteEditForm = function(note) { - var form = note.find('.current-note-edit-form'); - note.removeClass('is-editting'); + Notes.prototype.removeNoteEditForm = function($note) { + var form = $note.find('.current-note-edit-form'); + $note.removeClass('is-editing'); form.removeClass('current-note-edit-form'); - form.find('.js-edit-warning').hide(); + form.find('.js-finish-edit-warning').hide(); // Replace markdown textarea text with original note text. return form.find('.js-note-text').val(form.find('form.edit-note').data('original-note')); }; @@ -683,9 +741,9 @@ require('./task_list'); // to remove all. Using $(".note[id='noteId']") ensure we get all the notes, // where $("#noteId") would return only one. return function(i, el) { - var note, notes; - note = $(el); - notes = note.closest(".discussion-notes"); + var $note, $notes; + $note = $(el); + $notes = $note.closest(".discussion-notes"); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -693,18 +751,18 @@ require('./task_list'); } } - note.remove(); + $note.remove(); // check if this is the last note for this line - if (notes.find(".note").length === 0) { - var notesTr = notes.closest("tr"); + if ($notes.find(".note").length === 0) { + var notesTr = $notes.closest("tr"); // "Discussions" tab - notes.closest(".timeline-entry").remove(); + $notes.closest(".timeline-entry").remove(); // The notes tr can contain multiple lists of notes, like on the parallel diff if (notesTr.find('.discussion-notes').length > 1) { - notes.remove(); + $notes.remove(); } else { notesTr.remove(); } @@ -723,12 +781,11 @@ require('./task_list'); */ Notes.prototype.removeAttachment = function() { - var note; - note = $(this).closest(".note"); - note.find(".note-attachment").remove(); - note.find(".note-body > .note-text").show(); - note.find(".note-header").show(); - return note.find(".current-note-edit-form").remove(); + const $note = $(this).closest(".note"); + $note.find(".note-attachment").remove(); + $note.find(".note-body > .note-text").show(); + $note.find(".note-header").show(); + return $note.find(".current-note-edit-form").remove(); }; /* @@ -925,14 +982,6 @@ require('./task_list'); return this.refresh(); }; - Notes.prototype.updateCloseButton = function(e) { - var closebtn, form, textarea; - textarea = $(e.target); - form = textarea.parents('form'); - closebtn = form.find('.js-note-target-close'); - return closebtn.text(closebtn.data('original-text')); - }; - Notes.prototype.updateTargetButtons = function(e) { var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea; textarea = $(e.target); @@ -1004,19 +1053,21 @@ require('./task_list'); $editForm.find('.referenced-users').hide(); }; - Notes.prototype.updateNotesCount = function(updateCount) { - return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); + Notes.prototype.putConflictEditWarningInPlace = function(noteEntity, $note) { + if ($note.find('.js-conflict-edit-warning').length === 0) { + const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> + This comment has changed since you started editing, please review the + <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer"> + updated comment + </a> + to ensure information is not lost + </div>`); + $alert.insertAfter($note.find('.note-text')); + } }; - Notes.prototype.resolveDiscussion = function() { - var $this = $(this); - var discussionId = $this.attr('data-discussion-id'); - - $this - .closest('form') - .attr('data-discussion-id', discussionId) - .attr('data-resolve-all', 'true') - .attr('data-project-path', $this.attr('data-project-path')); + Notes.prototype.updateNotesCount = function(updateCount) { + return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); }; Notes.prototype.toggleCommitList = function(e) { @@ -1064,11 +1115,268 @@ require('./task_list'); return $form; }; - Notes.animateAppendNote = function(noteHTML, $notesList) { - const $note = window.$(noteHTML); + Notes.animateAppendNote = function(noteHtml, $notesList) { + const $note = $(noteHtml); - $note.addClass('fade-in').renderGFM(); + $note.addClass('fade-in-full').renderGFM(); $notesList.append($note); + return $note; + }; + + Notes.animateUpdateNote = function(noteHtml, $note) { + const $updatedNote = $(noteHtml); + + $updatedNote.addClass('fade-in').renderGFM(); + $note.replaceWith($updatedNote); + return $updatedNote; + }; + + /** + * Get data from Form attributes to use for saving/submitting comment. + */ + Notes.prototype.getFormData = function($form) { + return { + formData: $form.serialize(), + formContent: $form.find('.js-note-text').val(), + formAction: $form.attr('action'), + }; + }; + + /** + * Identify if comment has any slash commands + */ + Notes.prototype.hasSlashCommands = function(formContent) { + return REGEX_SLASH_COMMANDS.test(formContent); + }; + + /** + * Remove slash commands and leave comment with pure message + */ + Notes.prototype.stripSlashCommands = function(formContent) { + return formContent.replace(REGEX_SLASH_COMMANDS, '').trim(); + }; + + /** + * Create placeholder note DOM element populated with comment body + * that we will show while comment is being posted. + * Once comment is _actually_ posted on server, we will have final element + * in response that we will show in place of this temporary element. + */ + Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) { + const discussionClass = isDiscussionNote ? 'discussion' : ''; + const $tempNote = $( + `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <a href="/${currentUsername}"><span class="dummy-avatar"></span></a> + </div> + <div class="timeline-content ${discussionClass}"> + <div class="note-header"> + <div class="note-header-info"> + <a href="/${currentUsername}"> + <span class="hidden-xs">${currentUserFullname}</span> + <span class="note-headline-light">@${currentUsername}</span> + </a> + <span class="note-headline-light"> + <i class="fa fa-spinner fa-spin" aria-label="Comment is being posted" aria-hidden="true"></i> + </span> + </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>${formContent}</p> + </div> + </div> + </div> + </div> + </li>` + ); + + return $tempNote; + }; + + /** + * This method does following tasks step-by-step whenever a new comment + * is submitted by user (both main thread comments as well as discussion comments). + * + * 1) Get Form metadata + * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve + * 3) Build temporary placeholder element (using `createPlaceholderNote`) + * 4) Show placeholder note on UI + * 5) Perform network request to submit the note using `gl.utils.ajaxPost` + * a) If request is successfully completed + * 1. Remove placeholder element + * 2. Show submitted Note element + * 3. Perform post-submit errands + * a. Mark discussion as resolved if comment submission was for resolve. + * b. Reset comment form to original state. + * b) If request failed + * 1. Remove placeholder element + * 2. Show error Flash message about failure + */ + Notes.prototype.postComment = function(e) { + e.preventDefault(); + + // Get Form metadata + const $submitBtn = $(e.target); + let $form = $submitBtn.parents('form'); + const $closeBtn = $form.find('.js-note-target-close'); + const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion'; + const isMainForm = $form.hasClass('js-main-target-form'); + const isDiscussionForm = $form.hasClass('js-discussion-note-form'); + const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); + const { formData, formContent, formAction } = this.getFormData($form); + const uniqueId = _.uniqueId('tempNote_'); + let $notesContainer; + let tempFormContent; + + // Get reference to notes container based on type of comment + if (isDiscussionForm) { + $notesContainer = $form.parent('.discussion-notes').find('.notes'); + } else if (isMainForm) { + $notesContainer = $('ul.main-notes-list'); + } + + // If comment is to resolve discussion, disable submit buttons while + // comment posting is finished. + if (isDiscussionResolve) { + $submitBtn.disable(); + $form.find('.js-comment-submit-button').disable(); + } + + tempFormContent = formContent; + if (this.hasSlashCommands(formContent)) { + tempFormContent = this.stripSlashCommands(formContent); + } + + if (tempFormContent) { + // Show placeholder note + $notesContainer.append(this.createPlaceholderNote({ + formContent: tempFormContent, + uniqueId, + isDiscussionNote, + currentUsername: gon.current_username, + currentUserFullname: gon.current_user_fullname, + })); + } + + // Clear the form textarea + if ($notesContainer.length) { + if (isMainForm) { + this.resetMainTargetForm(e); + } else if (isDiscussionForm) { + this.removeDiscussionNoteForm($form); + } + } + + /* eslint-disable promise/catch-or-return */ + // Make request to submit comment on server + gl.utils.ajaxPost(formAction, formData) + .then((note) => { + // Submission successful! remove placeholder + $notesContainer.find(`#${uniqueId}`).remove(); + + // Check if this was discussion comment + if (isDiscussionForm) { + // Remove flash-container + $notesContainer.find('.flash-container').remove(); + + // If comment intends to resolve discussion, do the same. + if (isDiscussionResolve) { + $form + .attr('data-discussion-id', $submitBtn.data('discussion-id')) + .attr('data-resolve-all', 'true') + .attr('data-project-path', $submitBtn.data('project-path')); + } + + // Show final note element on UI + this.addDiscussionNote($form, note, $notesContainer.length === 0); + + // append flash-container to the Notes list + if ($notesContainer.length) { + $notesContainer.append('<div class="flash-container" style="display: none;"></div>'); + } + } else if (isMainForm) { // Check if this was main thread comment + // Show final note element on UI and perform form and action buttons cleanup + this.addNote($form, note); + this.reenableTargetFormSubmitButton(e); + } + + if (note.commands_changes) { + this.handleSlashCommands(note); + } + + $form.trigger('ajax:success', [note]); + }).fail(() => { + // Submission failed, remove placeholder note and show Flash error message + $notesContainer.find(`#${uniqueId}`).remove(); + + // Show form again on UI on failure + if (isDiscussionForm && $notesContainer.length) { + const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); + $.proxy(this.replyToDiscussionNote, replyButton[0], { target: replyButton[0] }).call(); + $form = $notesContainer.parent().find('form'); + } + + $form.find('.js-note-text').val(formContent); + this.reenableTargetFormSubmitButton(e); + this.addNoteError($form); + }); + + return $closeBtn.text($closeBtn.data('original-text')); + }; + + /** + * This method does following tasks step-by-step whenever an existing comment + * is updated by user (both main thread comments as well as discussion comments). + * + * 1) Get Form metadata + * 2) Update note element with new content + * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost` + * a) If request is successfully completed + * 1. Show submitted Note element + * b) If request failed + * 1. Revert Note element to original content + * 2. Show error Flash message about failure + */ + Notes.prototype.updateComment = function(e) { + e.preventDefault(); + + // Get Form metadata + const $submitBtn = $(e.target); + const $form = $submitBtn.parents('form'); + const $closeBtn = $form.find('.js-note-target-close'); + const $editingNote = $form.parents('.note.is-editing'); + const $noteBody = $editingNote.find('.js-task-list-container'); + const $noteBodyText = $noteBody.find('.note-text'); + const { formData, formContent, formAction } = this.getFormData($form); + + // Cache original comment content + const cachedNoteBodyText = $noteBodyText.html(); + + // Show updated comment content temporarily + $noteBodyText.html(formContent); + $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); + $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); + + /* eslint-disable promise/catch-or-return */ + // Make request to update comment on server + gl.utils.ajaxPost(formAction, formData) + .then((note) => { + // Submission successful! render final note element + this.updateNote(null, note, null); + }) + .fail(() => { + // Submission failed, revert back to original note + $noteBodyText.html(cachedNoteBodyText); + $editingNote.removeClass('being-posted fade-in'); + $editingNote.find('.fa.fa-spinner').remove(); + + // Show Flash message about failure + this.updateNoteError(); + }); + + return $closeBtn.text($closeBtn.data('original-text')); }; return Notes; diff --git a/app/assets/javascripts/pipelines/components/stage.js b/app/assets/javascripts/pipelines/components/stage.js deleted file mode 100644 index 203485f2990..00000000000 --- a/app/assets/javascripts/pipelines/components/stage.js +++ /dev/null @@ -1,115 +0,0 @@ -/* global Flash */ -import StatusIconEntityMap from '../../ci_status_icons'; - -export default { - props: { - stage: { - type: Object, - required: true, - }, - }, - - data() { - return { - builds: '', - spinner: '<span class="fa fa-spinner fa-spin"></span>', - }; - }, - - updated() { - if (this.builds) { - this.stopDropdownClickPropagation(); - } - }, - - methods: { - fetchBuilds(e) { - const ariaExpanded = e.currentTarget.attributes['aria-expanded']; - - if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null; - - return this.$http.get(this.stage.dropdown_path) - .then((response) => { - this.builds = JSON.parse(response.body).html; - }) - .catch(() => { - // If dropdown is opened we'll close it. - if (this.$el.classList.contains('open')) { - $(this.$refs.dropdown).dropdown('toggle'); - } - - const flash = new Flash('Something went wrong on our end.'); - return flash; - }); - }, - - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')) - .on('click', (e) => { - e.stopPropagation(); - }); - }, - }, - computed: { - buildsOrSpinner() { - return this.builds ? this.builds : this.spinner; - }, - dropdownClass() { - if (this.builds) return 'js-builds-dropdown-container'; - return 'js-builds-dropdown-loading builds-dropdown-loading'; - }, - buildStatus() { - return `Build: ${this.stage.status.label}`; - }, - tooltip() { - return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; - }, - triggerButtonClass() { - return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; - }, - svgHTML() { - return StatusIconEntityMap[this.stage.status.icon]; - }, - }, - template: ` - <div> - <button - @click="fetchBuilds($event)" - :class="triggerButtonClass" - :title="stage.title" - data-placement="top" - data-toggle="dropdown" - type="button" - :aria-label="stage.title" - ref="dropdown"> - <span - v-html="svgHTML" - aria-hidden="true"> - </span> - <i - class="fa fa-caret-down" - aria-hidden="true" /> - </button> - <ul - ref="dropdown-content" - class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> - <div - class="arrow-up" - aria-hidden="true"></div> - <div - :class="dropdownClass" - class="js-builds-dropdown-list scrollable-menu" - v-html="buildsOrSpinner"> - </div> - </ul> - </div> - `, -}; diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue new file mode 100644 index 00000000000..2e485f951a1 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -0,0 +1,173 @@ +<script> + +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +/* global Flash */ +import StatusIconEntityMap from '../../ci_status_icons'; + +export default { + props: { + stage: { + type: Object, + required: true, + }, + + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + isLoading: false, + dropdownContent: '', + endpoint: this.stage.dropdown_path, + }; + }, + + updated() { + if (this.dropdownContent.length > 0) { + this.stopDropdownClickPropagation(); + } + }, + + watch: { + updateDropdown() { + if (this.updateDropdown && + this.isDropdownOpen() && + !this.isLoading) { + this.fetchJobs(); + } + }, + }, + + methods: { + onClickStage() { + if (!this.isDropdownOpen()) { + this.isLoading = true; + this.fetchJobs(); + } + }, + + fetchJobs() { + this.$http.get(this.endpoint) + .then((response) => { + this.dropdownContent = response.json().html; + this.isLoading = false; + }) + .catch(() => { + this.closeDropdown(); + this.isLoading = false; + + const flash = new Flash('Something went wrong on our end.'); + return flash; + }); + }, + + /** + * When the user right clicks or cmd/ctrl + click in the job name + * the dropdown should not be closed and the link should open in another tab, + * so we stop propagation of the click event inside the dropdown. + * + * Since this component is rendered multiple times per page we need to guarantee we only + * target the click event of this component. + */ + stopDropdownClickPropagation() { + $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')) + .on('click', (e) => { + e.stopPropagation(); + }); + }, + + closeDropdown() { + if (this.isDropdownOpen()) { + $(this.$refs.dropdown).dropdown('toggle'); + } + }, + + isDropdownOpen() { + return this.$el.classList.contains('open'); + }, + }, + + computed: { + dropdownClass() { + return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading'; + }, + + triggerButtonClass() { + return `ci-status-icon-${this.stage.status.group}`; + }, + + svgIcon() { + return StatusIconEntityMap[this.stage.status.icon]; + }, + }, +}; +</script> + +<template> + <div class="dropdown"> + <button + :class="triggerButtonClass" + @click="onClickStage" + class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button" + :title="stage.title" + data-placement="top" + data-toggle="dropdown" + type="button" + id="stageDropdown" + aria-haspopup="true" + aria-expanded="false"> + + <span + v-html="svgIcon" + aria-hidden="true" + :aria-label="stage.title"> + </span> + + <i + class="fa fa-caret-down" + aria-hidden="true"> + </i> + </button> + + <ul + class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" + aria-labelledby="stageDropdown"> + + <li + :class="dropdownClass" + class="js-builds-dropdown-list scrollable-menu"> + + <div + class="text-center" + v-if="isLoading"> + <i + class="fa fa-spin fa-spinner" + aria-hidden="true" + aria-label="Loading"> + </i> + </div> + + <ul + v-else + v-html="dropdownContent"> + </ul> + </li> + </ul> + </div> +</script> diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 93d4818231f..934bd7deb31 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -49,6 +49,7 @@ export default { isLoading: false, hasError: false, isMakingRequest: false, + updateGraphDropdown: false, }; }, @@ -198,15 +199,21 @@ export default { this.store.storePagination(response.headers); this.isLoading = false; + this.updateGraphDropdown = true; }, errorCallback() { this.hasError = true; this.isLoading = false; + this.updateGraphDropdown = false; }, setIsMakingRequest(isMakingRequest) { this.isMakingRequest = isMakingRequest; + + if (isMakingRequest) { + this.updateGraphDropdown = false; + } }, }, @@ -263,7 +270,9 @@ export default { <pipelines-table-component :pipelines="state.pipelines" - :service="service"/> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </div> <gl-pagination diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 255cd513490..b21f84b4545 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -40,6 +40,6 @@ export default class PipelinesService { * @return {Promise} */ postAction(endpoint) { - return Vue.http.post(endpoint, {}, { emulateJSON: true }); + return Vue.http.post(`${endpoint}.json`); } } diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 07eea98e737..4a3df2fd465 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -2,8 +2,9 @@ // MarkdownPreview // -// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview, -// and showing a warning when more than `x` users are referenced. +// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview +// (including the explanation of slash commands), and showing a warning when +// more than `x` users are referenced. // (function () { var lastTextareaPreviewed; @@ -17,32 +18,45 @@ // Minimum number of users referenced before triggering a warning MarkdownPreview.prototype.referenceThreshold = 10; + MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.'; MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function ($form) { var mdText; var preview = $form.find('.js-md-preview'); + var url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } mdText = $form.find('textarea.markdown-area').val(); if (mdText.trim().length === 0) { - preview.text('Nothing to preview.'); + preview.text(this.emptyMessage); this.hideReferencedUsers($form); } else { preview.addClass('md-preview-loading').text('Loading...'); - this.fetchMarkdownPreview(mdText, (function (response) { - preview.removeClass('md-preview-loading').html(response.body); + this.fetchMarkdownPreview(mdText, url, (function (response) { + var body; + if (response.body.length > 0) { + body = response.body; + } else { + body = this.emptyMessage; + } + + preview.removeClass('md-preview-loading').html(body); preview.renderGFM(); this.renderReferencedUsers(response.references.users, $form); + + if (response.references.commands) { + this.renderReferencedCommands(response.references.commands, $form); + } }).bind(this)); } }; - MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) { - if (!window.preview_markdown_path) { + MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { + if (!url) { return; } if (text === this.ajaxCache.text) { @@ -51,7 +65,7 @@ } $.ajax({ type: 'POST', - url: window.preview_markdown_path, + url: url, data: { text: text }, @@ -83,6 +97,22 @@ } }; + MarkdownPreview.prototype.hideReferencedCommands = function ($form) { + $form.find('.referenced-commands').hide(); + }; + + MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) { + var referencedCommands; + referencedCommands = $form.find('.referenced-commands'); + if (commands.length > 0) { + referencedCommands.html(commands); + referencedCommands.show(); + } else { + referencedCommands.html(''); + referencedCommands.hide(); + } + }; + return MarkdownPreview; }()); @@ -137,6 +167,8 @@ $form.find('.md-write-holder').show(); $form.find('textarea.markdown-area').focus(); $form.find('.md-preview-holder').hide(); + + markdownPreview.hideReferencedCommands($form); }); $(document).on('markdown-preview:toggle', function (e, keyboardEvent) { diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index f944fcc5a58..738e710deb9 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -112,7 +112,8 @@ import Cookies from 'js-cookie'; toggleLabel: function(obj, $el) { return $el.text().trim(); }, - clicked: function(selected, $el, e) { + clicked: function(options) { + const { e } = options; e.preventDefault(); if ($('input[name="ref"]').length) { var $form = $dropdown.closest('form'); diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js index e7fff57ff45..42993a252c3 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js @@ -19,7 +19,9 @@ return 'Select'; } }, - clicked(item, $el, e) { + clicked(opts) { + const { e } = opts; + e.preventDefault(); onSelect(); } diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js index 1d4bb8a13d6..bc6110fcd4e 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js @@ -35,7 +35,8 @@ class ProtectedBranchDropdown { return _.escape(protectedBranch.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { + clicked: (options) => { + const { $el, e } = options; e.preventDefault(); this.onSelect(); } diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js index fff83f3af3b..d4c9a91a74a 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js @@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown { } return 'Select'; }, - clicked(item, $el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); onSelect(); }, }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js index 5ff4e443262..068e9698e1d 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -39,8 +39,8 @@ export default class ProtectedTagDropdown { return _.escape(protectedTag.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { - e.preventDefault(); + clicked: (options) => { + options.e.preventDefault(); this.onSelect(); }, }); diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js new file mode 100644 index 00000000000..5325e495815 --- /dev/null +++ b/app/assets/javascripts/raven/index.js @@ -0,0 +1,16 @@ +import RavenConfig from './raven_config'; + +const index = function index() { + RavenConfig.init({ + sentryDsn: gon.sentry_dsn, + currentUserId: gon.current_user_id, + whitelistUrls: [gon.gitlab_url], + isProduction: process.env.NODE_ENV, + }); + + return RavenConfig; +}; + +index(); + +export default index; diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js new file mode 100644 index 00000000000..c7fe1cacf49 --- /dev/null +++ b/app/assets/javascripts/raven/raven_config.js @@ -0,0 +1,100 @@ +import Raven from 'raven-js'; + +const IGNORE_ERRORS = [ + // Random plugins/extensions + 'top.GLOBALS', + // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html + 'originalCreateNotification', + 'canvas.contentDocument', + 'MyApp_RemoveAllHighlights', + 'http://tt.epicplay.com', + 'Can\'t find variable: ZiteReader', + 'jigsaw is not defined', + 'ComboSearch is not defined', + 'http://loading.retry.widdit.com/', + 'atomicFindClose', + // Facebook borked + 'fb_xd_fragment', + // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to + // reduce this. (thanks @acdha) + // See http://stackoverflow.com/questions/4113268 + 'bmi_SafeAddOnload', + 'EBCallBackMessageReceived', + // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx + 'conduitPage', +]; + +const IGNORE_URLS = [ + // Facebook flakiness + /graph\.facebook\.com/i, + // Facebook blocked + /connect\.facebook\.net\/en_US\/all\.js/i, + // Woopra flakiness + /eatdifferent\.com\.woopra-ns\.com/i, + /static\.woopra\.com\/js\/woopra\.js/i, + // Chrome extensions + /extensions\//i, + /^chrome:\/\//i, + // Other plugins + /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb + /webappstoolbarba\.texthelp\.com\//i, + /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, +]; + +const SAMPLE_RATE = 95; + +const RavenConfig = { + IGNORE_ERRORS, + IGNORE_URLS, + SAMPLE_RATE, + init(options = {}) { + this.options = options; + + this.configure(); + this.bindRavenErrors(); + if (this.options.currentUserId) this.setUser(); + }, + + configure() { + Raven.config(this.options.sentryDsn, { + whitelistUrls: this.options.whitelistUrls, + environment: this.options.isProduction ? 'production' : 'development', + ignoreErrors: this.IGNORE_ERRORS, + ignoreUrls: this.IGNORE_URLS, + shouldSendCallback: this.shouldSendSample.bind(this), + }).install(); + }, + + setUser() { + Raven.setUserContext({ + id: this.options.currentUserId, + }); + }, + + bindRavenErrors() { + window.$(document).on('ajaxError.raven', this.handleRavenErrors); + }, + + handleRavenErrors(event, req, config, err) { + const error = err || req.statusText; + const responseText = req.responseText || 'Unknown response text'; + + Raven.captureMessage(error, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: responseText, + error, + event, + }, + }); + }, + + shouldSendSample() { + return Math.random() * 100 <= this.SAMPLE_RATE; + }, +}; + +export default RavenConfig; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js new file mode 100644 index 00000000000..a9ad3708514 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js @@ -0,0 +1,41 @@ +export default { + name: 'AssigneeTitle', + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfAssignees: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + assigneeTitle() { + const assignees = this.numberOfAssignees; + return assignees > 1 ? `${assignees} Assignees` : 'Assignee'; + }, + }, + template: ` + <div class="title hide-collapsed"> + {{assigneeTitle}} + <i + v-if="loading" + aria-hidden="true" + class="fa fa-spinner fa-spin block-loading" + /> + <a + v-if="editable" + class="edit-link pull-right" + href="#" + > + Edit + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js new file mode 100644 index 00000000000..7e5feac622c --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js @@ -0,0 +1,224 @@ +export default { + name: 'Assignees', + data() { + return { + defaultRenderCount: 5, + defaultMaxCounter: 99, + showLess: true, + }; + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasAssignees() { + return this.users.length > 0; + }, + hasNoUsers() { + return !this.users.length; + }, + hasOneUser() { + return this.users.length === 1; + }, + renderShowMoreSection() { + return this.users.length > this.defaultRenderCount; + }, + numberOfHiddenAssignees() { + return this.users.length - this.defaultRenderCount; + }, + isHiddenAssignees() { + return this.numberOfHiddenAssignees > 0; + }, + hiddenAssigneesLabel() { + return `+ ${this.numberOfHiddenAssignees} more`; + }, + collapsedTooltipTitle() { + const maxRender = Math.min(this.defaultRenderCount, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (this.users.length > maxRender) { + names.push(`+ ${this.users.length - maxRender} more`); + } + + return names.join(', '); + }, + sidebarAvatarCounter() { + let counter = `+${this.users.length - 1}`; + + if (this.users.length > this.defaultMaxCounter) { + counter = `${this.defaultMaxCounter}+`; + } + + return counter; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + toggleShowLess() { + this.showLess = !this.showLess; + }, + renderAssignee(index) { + return !this.showLess || (index < this.defaultRenderCount && this.showLess); + }, + avatarUrl(user) { + return user.avatar || user.avatar_url; + }, + assigneeUrl(user) { + return `${this.rootPath}${user.username}`; + }, + assigneeAlt(user) { + return `${user.name}'s avatar`; + }, + assigneeUsername(user) { + return `@${user.username}`; + }, + shouldRenderCollapsedAssignee(index) { + const firstTwo = this.users.length <= 2 && index <= 2; + + return index === 0 || firstTwo; + }, + }, + template: ` + <div> + <div + class="sidebar-collapsed-icon sidebar-collapsed-user" + :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + data-container="body" + data-placement="left" + :title="collapsedTooltipTitle" + > + <i + v-if="hasNoUsers" + aria-label="No Assignee" + class="fa fa-user" + /> + <button + type="button" + class="btn-link" + v-for="(user, index) in users" + v-if="shouldRenderCollapsedAssignee(index)" + > + <img + width="24" + class="avatar avatar-inline s24" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + <span class="author"> + {{ user.name }} + </span> + </button> + <button + v-if="hasMoreThanTwoAssignees" + class="btn-link" + type="button" + > + <span + class="avatar-counter sidebar-avatar-counter" + > + {{ sidebarAvatarCounter }} + </span> + </button> + </div> + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value"> + No assignee + <template v-if="editable"> + - + <button + type="button" + class="btn-link" + @click="assignSelf" + > + assign yourself + </button> + </template> + </span> + </template> + <template v-else-if="hasOneUser"> + <a + class="author_link bold" + :href="assigneeUrl(firstUser)" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(firstUser)" + :src="avatarUrl(firstUser)" + /> + <span class="author"> + {{ firstUser.name }} + </span> + <span class="username"> + {{ assigneeUsername(firstUser) }} + </span> + </a> + </template> + <template v-else> + <div class="user-list"> + <div + class="user-item" + v-for="(user, index) in users" + v-if="renderAssignee(index)" + > + <a + class="user-link has-tooltip" + data-placement="bottom" + :href="assigneeUrl(user)" + :data-title="user.name" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + </a> + </div> + </div> + <div + v-if="renderShowMoreSection" + class="user-list-more" + > + <button + type="button" + class="btn-link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else> + - show less + </template> + </button> + </div> + </template> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js new file mode 100644 index 00000000000..1488a66c695 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -0,0 +1,84 @@ +/* global Flash */ + +import AssigneeTitle from './assignee_title'; +import Assignees from './assignees'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +import eventHub from '../../event_hub'; + +export default { + name: 'SidebarAssignees', + data() { + return { + mediator: new Mediator(), + store: new Store(), + loading: false, + field: '', + }; + }, + components: { + 'assignee-title': AssigneeTitle, + assignees: Assignees, + }, + methods: { + assignSelf() { + // Notify gl dropdown that we are now assigning to current user + this.$el.parentElement.dispatchEvent(new Event('assignYourself')); + + this.mediator.assignYourself(); + this.saveAssignees(); + }, + saveAssignees() { + this.loading = true; + + function setLoadingFalse() { + this.loading = false; + } + + this.mediator.saveAssignees(this.field) + .then(setLoadingFalse.bind(this)) + .catch(() => { + setLoadingFalse(); + return new Flash('Error occurred when saving assignees'); + }); + }, + }, + created() { + this.removeAssignee = this.store.removeAssignee.bind(this.store); + this.addAssignee = this.store.addAssignee.bind(this.store); + this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); + + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); + }, + beforeMount() { + this.field = this.$el.dataset.field; + }, + template: ` + <div> + <assignee-title + :number-of-assignees="store.assignees.length" + :loading="loading" + :editable="store.editable" + /> + <assignees + class="value" + :root-path="store.rootPath" + :users="store.assignees" + :editable="store.editable" + @assign-self="assignSelf" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js new file mode 100644 index 00000000000..0da265053bd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js @@ -0,0 +1,97 @@ +import stopwatchSvg from 'icons/_icon_stopwatch.svg'; + +import '../../../lib/utils/pretty_time'; + +export default { + name: 'time-tracking-collapsed-state', + props: { + showComparisonState: { + type: Boolean, + required: true, + }, + showSpentOnlyState: { + type: Boolean, + required: true, + }, + showEstimateOnlyState: { + type: Boolean, + required: true, + }, + showNoTimeTrackingState: { + type: Boolean, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: false, + default: '', + }, + timeEstimateHumanReadable: { + type: String, + required: false, + default: '', + }, + }, + computed: { + timeSpent() { + return this.abbreviateTime(this.timeSpentHumanReadable); + }, + timeEstimate() { + return this.abbreviateTime(this.timeEstimateHumanReadable); + }, + divClass() { + if (this.showComparisonState) { + return 'compare'; + } else if (this.showEstimateOnlyState) { + return 'estimate-only'; + } else if (this.showSpentOnlyState) { + return 'spend-only'; + } else if (this.showNoTimeTrackingState) { + return 'no-tracking'; + } + + return ''; + }, + spanClass() { + if (this.showComparisonState) { + return ''; + } else if (this.showEstimateOnlyState || this.showSpentOnlyState) { + return 'bold'; + } else if (this.showNoTimeTrackingState) { + return 'no-value'; + } + + return ''; + }, + text() { + if (this.showComparisonState) { + return `${this.timeSpent} / ${this.timeEstimate}`; + } else if (this.showEstimateOnlyState) { + return `-- / ${this.timeEstimate}`; + } else if (this.showSpentOnlyState) { + return `${this.timeSpent} / --`; + } else if (this.showNoTimeTrackingState) { + return 'None'; + } + + return ''; + }, + }, + methods: { + abbreviateTime(timeStr) { + return gl.utils.prettyTime.abbreviateTime(timeStr); + }, + }, + template: ` + <div class="sidebar-collapsed-icon"> + ${stopwatchSvg} + <div class="time-tracking-collapsed-summary"> + <div :class="divClass"> + <span :class="spanClass"> + {{ text }} + </span> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js new file mode 100644 index 00000000000..40f5c89c5bb --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js @@ -0,0 +1,98 @@ +import '../../../lib/utils/pretty_time'; + +const prettyTime = gl.utils.prettyTime; + +export default { + name: 'time-tracking-comparison-pane', + props: { + timeSpent: { + type: Number, + required: true, + }, + timeEstimate: { + type: Number, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: true, + }, + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + computed: { + parsedRemaining() { + const diffSeconds = this.timeEstimate - this.timeSpent; + return prettyTime.parseSeconds(diffSeconds); + }, + timeRemainingHumanReadable() { + return prettyTime.stringifyTime(this.parsedRemaining); + }, + timeRemainingTooltip() { + const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; + return `${prefix} ${this.timeRemainingHumanReadable}`; + }, + /* Diff values for comparison meter */ + timeRemainingMinutes() { + return this.timeEstimate - this.timeSpent; + }, + timeRemainingPercent() { + return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; + }, + timeRemainingStatusClass() { + return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; + }, + /* Parsed time values */ + parsedEstimate() { + return prettyTime.parseSeconds(this.timeEstimate); + }, + parsedSpent() { + return prettyTime.parseSeconds(this.timeSpent); + }, + }, + template: ` + <div class="time-tracking-comparison-pane"> + <div + class="compare-meter" + data-toggle="tooltip" + data-placement="top" + role="timeRemainingDisplay" + :aria-valuenow="timeRemainingTooltip" + :title="timeRemainingTooltip" + :data-original-title="timeRemainingTooltip" + :class="timeRemainingStatusClass" + > + <div + class="meter-container" + role="timeSpentPercent" + :aria-valuenow="timeRemainingPercent" + > + <div + :style="{ width: timeRemainingPercent }" + class="meter-fill" + /> + </div> + <div class="compare-display-container"> + <div class="compare-display pull-left"> + <span class="compare-label"> + Spent + </span> + <span class="compare-value spent"> + {{ timeSpentHumanReadable }} + </span> + </div> + <div class="compare-display estimated pull-right"> + <span class="compare-label"> + Est + </span> + <span class="compare-value"> + {{ timeEstimateHumanReadable }} + </span> + </div> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js new file mode 100644 index 00000000000..ad1b9179db0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js @@ -0,0 +1,17 @@ +export default { + name: 'time-tracking-estimate-only-pane', + props: { + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-estimate-only-pane"> + <span class="bold"> + Estimated: + </span> + {{ timeEstimateHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js new file mode 100644 index 00000000000..b2a77462fe0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js @@ -0,0 +1,44 @@ +export default { + name: 'time-tracking-help-state', + props: { + rootPath: { + type: String, + required: true, + }, + }, + computed: { + href() { + return `${this.rootPath}help/workflow/time_tracking.md`; + }, + }, + template: ` + <div class="time-tracking-help-state"> + <div class="time-tracking-info"> + <h4> + Track time with slash commands + </h4> + <p> + Slash commands can be used in the issues description and comment boxes. + </p> + <p> + <code> + /estimate + </code> + will update the estimated time with the latest command. + </p> + <p> + <code> + /spend + </code> + will update the sum of the time spent. + </p> + <a + class="btn btn-default learn-more-button" + :href="href" + > + Learn more + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js new file mode 100644 index 00000000000..d1dd1dcdd27 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js @@ -0,0 +1,10 @@ +export default { + name: 'time-tracking-no-tracking-pane', + template: ` + <div class="time-tracking-no-tracking-pane"> + <span class="no-value"> + No estimate or time spent + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js new file mode 100644 index 00000000000..244b67b3ad9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -0,0 +1,51 @@ +import '~/smart_interval'; + +import timeTracker from './time_tracker'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + 'issuable-time-tracker': timeTracker, + }, + methods: { + listenForSlashCommands() { + $(document).on('ajax:success', '.gfm-form', this.slashCommandListened); + }, + slashCommandListened(e, data) { + const subscribedCommands = ['spend_time', 'time_estimate']; + let changedCommands; + if (data !== undefined) { + changedCommands = data.commands_changes + ? Object.keys(data.commands_changes) + : []; + } else { + changedCommands = []; + } + if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { + this.mediator.fetch(); + } + }, + }, + mounted() { + this.listenForSlashCommands(); + }, + template: ` + <div class="block"> + <issuable-time-tracker + :time_estimate="store.timeEstimate" + :time_spent="store.totalTimeSpent" + :human_time_estimate="store.humanTimeEstimate" + :human_time_spent="store.humanTotalTimeSpent" + :rootPath="store.rootPath" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js new file mode 100644 index 00000000000..bf987562647 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js @@ -0,0 +1,15 @@ +export default { + name: 'time-tracking-spent-only-pane', + props: { + timeSpentHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-spend-only-pane"> + <span class="bold">Spent:</span> + {{ timeSpentHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js new file mode 100644 index 00000000000..ed0d71a4f79 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js @@ -0,0 +1,163 @@ +import timeTrackingHelpState from './help_state'; +import timeTrackingCollapsedState from './collapsed_state'; +import timeTrackingSpentOnlyPane from './spent_only_pane'; +import timeTrackingNoTrackingPane from './no_tracking_pane'; +import timeTrackingEstimateOnlyPane from './estimate_only_pane'; +import timeTrackingComparisonPane from './comparison_pane'; + +import eventHub from '../../event_hub'; + +export default { + name: 'issuable-time-tracker', + props: { + time_estimate: { + type: Number, + required: true, + }, + time_spent: { + type: Number, + required: true, + }, + human_time_estimate: { + type: String, + required: false, + default: '', + }, + human_time_spent: { + type: String, + required: false, + default: '', + }, + rootPath: { + type: String, + required: true, + }, + }, + data() { + return { + showHelp: false, + }; + }, + components: { + 'time-tracking-collapsed-state': timeTrackingCollapsedState, + 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, + 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, + 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, + 'time-tracking-comparison-pane': timeTrackingComparisonPane, + 'time-tracking-help-state': timeTrackingHelpState, + }, + computed: { + timeSpent() { + return this.time_spent; + }, + timeEstimate() { + return this.time_estimate; + }, + timeEstimateHumanReadable() { + return this.human_time_estimate; + }, + timeSpentHumanReadable() { + return this.human_time_spent; + }, + hasTimeSpent() { + return !!this.timeSpent; + }, + hasTimeEstimate() { + return !!this.timeEstimate; + }, + showComparisonState() { + return this.hasTimeEstimate && this.hasTimeSpent; + }, + showEstimateOnlyState() { + return this.hasTimeEstimate && !this.hasTimeSpent; + }, + showSpentOnlyState() { + return this.hasTimeSpent && !this.hasTimeEstimate; + }, + showNoTimeTrackingState() { + return !this.hasTimeEstimate && !this.hasTimeSpent; + }, + showHelpState() { + return !!this.showHelp; + }, + }, + methods: { + toggleHelpState(show) { + this.showHelp = show; + }, + update(data) { + this.time_estimate = data.time_estimate; + this.time_spent = data.time_spent; + this.human_time_estimate = data.human_time_estimate; + this.human_time_spent = data.human_time_spent; + }, + }, + created() { + eventHub.$on('timeTracker:updateData', this.update); + }, + template: ` + <div + class="time_tracker time-tracking-component-wrap" + v-cloak + > + <time-tracking-collapsed-state + :show-comparison-state="showComparisonState" + :show-no-time-tracking-state="showNoTimeTrackingState" + :show-help-state="showHelpState" + :show-spent-only-state="showSpentOnlyState" + :show-estimate-only-state="showEstimateOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <div class="title hide-collapsed"> + Time tracking + <div + class="help-button pull-right" + v-if="!showHelpState" + @click="toggleHelpState(true)" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + /> + </div> + <div + class="close-help-button pull-right" + v-if="showHelpState" + @click="toggleHelpState(false)" + > + <i + class="fa fa-close" + aria-hidden="true" + /> + </div> + </div> + <div class="time-tracking-content hide-collapsed"> + <time-tracking-estimate-only-pane + v-if="showEstimateOnlyState" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <time-tracking-spent-only-pane + v-if="showSpentOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + /> + <time-tracking-no-tracking-pane + v-if="showNoTimeTrackingState" + /> + <time-tracking-comparison-pane + v-if="showComparisonState" + :time-estimate="timeEstimate" + :time-spent="timeSpent" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <transition name="help-state-toggle"> + <time-tracking-help-state + v-if="showHelpState" + :rootPath="rootPath" + /> + </transition> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/sidebar/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js new file mode 100644 index 00000000000..5a82d01dc41 --- /dev/null +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class SidebarService { + constructor(endpoint) { + if (!SidebarService.singleton) { + this.endpoint = endpoint; + + SidebarService.singleton = this; + } + + return SidebarService.singleton; + } + + get() { + return Vue.http.get(this.endpoint); + } + + update(key, data) { + return Vue.http.put(this.endpoint, { + [key]: data, + }, { + emulateJSON: true, + }); + } +} diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js new file mode 100644 index 00000000000..2b02af87d8a --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; +import sidebarAssignees from './components/assignees/sidebar_assignees'; + +import Mediator from './sidebar_mediator'; + +function domContentLoaded() { + const mediator = new Mediator(gl.sidebarOptions); + mediator.fetch(); + + const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + + // Only create the sidebarAssignees vue app if it is found in the DOM + // We currently do not use sidebarAssignees for the MR page + if (sidebarAssigneesEl) { + new Vue(sidebarAssignees).$mount(sidebarAssigneesEl); + } + + new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); +} + +document.addEventListener('DOMContentLoaded', domContentLoaded); + +export default domContentLoaded; diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js new file mode 100644 index 00000000000..5ccfb4ee9c1 --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -0,0 +1,38 @@ +/* global Flash */ + +import Service from './services/sidebar_service'; +import Store from './stores/sidebar_store'; + +export default class SidebarMediator { + constructor(options) { + if (!SidebarMediator.singleton) { + this.store = new Store(options); + this.service = new Service(options.endpoint); + SidebarMediator.singleton = this; + } + + return SidebarMediator.singleton; + } + + assignYourself() { + this.store.addAssignee(this.store.currentUser); + } + + saveAssignees(field) { + const selected = this.store.assignees.map(u => u.id); + + // If there are no ids, that means we have to unassign (which is id = 0) + // And it only accepts an array, hence [0] + return this.service.update(field, selected.length === 0 ? [0] : selected); + } + + fetch() { + this.service.get() + .then((response) => { + const data = response.json(); + this.store.setAssigneeData(data); + this.store.setTimeTrackingData(data); + }) + .catch(() => new Flash('Error occured when fetching sidebar data')); + } +} diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js new file mode 100644 index 00000000000..2d44c05bb8d --- /dev/null +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -0,0 +1,52 @@ +export default class SidebarStore { + constructor(store) { + if (!SidebarStore.singleton) { + const { currentUser, rootPath, editable } = store; + this.currentUser = currentUser; + this.rootPath = rootPath; + this.editable = editable; + this.timeEstimate = 0; + this.totalTimeSpent = 0; + this.humanTimeEstimate = ''; + this.humanTimeSpent = ''; + this.assignees = []; + + SidebarStore.singleton = this; + } + + return SidebarStore.singleton; + } + + setAssigneeData(data) { + if (data.assignees) { + this.assignees = data.assignees; + } + } + + setTimeTrackingData(data) { + this.timeEstimate = data.time_estimate; + this.totalTimeSpent = data.total_time_spent; + this.humanTimeEstimate = data.human_time_estimate; + this.humanTotalTimeSpent = data.human_total_time_spent; + } + + addAssignee(assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(assignee); + } + } + + findAssignee(findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee(removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees() { + this.assignees = []; + } +} diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js index d811d1cd53a..2587facc582 100644 --- a/app/assets/javascripts/signin_tabs_memoizer.js +++ b/app/assets/javascripts/signin_tabs_memoizer.js @@ -1,5 +1,7 @@ /* eslint no-param-reassign: ["error", { "props": false }]*/ /* eslint no-new: "off" */ +import AccessorUtilities from './lib/utils/accessor'; + ((global) => { /** * Memorize the last selected tab after reloading a page. @@ -9,6 +11,8 @@ constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) { this.currentTabKey = currentTabKey; this.tabSelector = tabSelector; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + this.bootstrap(); } @@ -37,11 +41,15 @@ } saveData(val) { - localStorage.setItem(this.currentTabKey, val); + if (!this.isLocalStorageAvailable) return undefined; + + return window.localStorage.setItem(this.currentTabKey, val); } readData() { - return localStorage.getItem(this.currentTabKey); + if (!this.isLocalStorageAvailable) return null; + + return window.localStorage.getItem(this.currentTabKey); } } diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js deleted file mode 100644 index d8191605128..00000000000 --- a/app/assets/javascripts/subbable_resource.js +++ /dev/null @@ -1,51 +0,0 @@ -(() => { -/* -* SubbableResource can be extended to provide a pubsub-style service for one-off REST -* calls. Subscribe by passing a callback or render method you will use to handle responses. - * -* */ - - class SubbableResource { - constructor(resourcePath) { - this.endpoint = resourcePath; - - // TODO: Switch to axios.create - this.resource = $.ajax; - this.subscribers = []; - } - - subscribe(callback) { - this.subscribers.push(callback); - } - - publish(newResponse) { - const responseCopy = _.extend({}, newResponse); - this.subscribers.forEach((fn) => { - fn(responseCopy); - }); - return newResponse; - } - - get(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - post(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - put(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - delete(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - } - - gl.SubbableResource = SubbableResource; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js index 8b25f43ffc7..0cd591c7320 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/subscription_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js index 754d448564f..32ffa2f0ac0 100644 --- a/app/assets/javascripts/users/calendar.js +++ b/app/assets/javascripts/users/calendar.js @@ -168,15 +168,23 @@ import d3 from 'd3'; }; Calendar.prototype.renderKey = function() { - var keyColors; - keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; - return this.svg.append('g').attr('transform', "translate(18, " + (this.daySizeWithSpace * 8 + 16) + ")").selectAll('rect').data(keyColors).enter().append('rect').attr('width', this.daySize).attr('height', this.daySize).attr('x', (function(_this) { - return function(color, i) { - return _this.daySizeWithSpace * i; - }; - })(this)).attr('y', 0).attr('fill', function(color) { - return color; - }); + const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions']; + const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; + + this.svg.append('g') + .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`) + .selectAll('rect') + .data(keyColors) + .enter() + .append('rect') + .attr('width', this.daySize) + .attr('height', this.daySize) + .attr('x', (color, i) => this.daySizeWithSpace * i) + .attr('y', 0) + .attr('fill', color => color) + .attr('class', 'js-tooltip') + .attr('title', (color, i) => keyValues[i]) + .attr('data-container', 'body'); }; Calendar.prototype.initColor = function() { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 0344ce9ffb4..be29b08c343 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ -/* global ListUser */ + +import eventHub from './sidebar/event_hub'; (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, @@ -30,7 +31,7 @@ $els.each((function(_this) { return function(i, dropdown) { var options = {}; - var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove; + var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; $dropdown = $(dropdown); options.projectId = $dropdown.data('project-id'); options.groupId = $dropdown.data('group-id'); @@ -38,11 +39,11 @@ options.todoFilter = $dropdown.data('todo-filter'); options.todoStateFilter = $dropdown.data('todo-state-filter'); showNullUser = $dropdown.data('null-user'); + defaultNullUser = $dropdown.data('null-user-default'); showMenuAbove = $dropdown.data('showMenuAbove'); showAnyUser = $dropdown.data('any-user'); firstUser = $dropdown.data('first-user'); options.authorId = $dropdown.data('author-id'); - selectedId = $dropdown.data('selected'); defaultLabel = $dropdown.data('default-label'); issueURL = $dropdown.data('issueUpdate'); $selectbox = $dropdown.closest('.selectbox'); @@ -51,43 +52,118 @@ $value = $block.find('.value'); $collapsedSidebar = $block.find('.sidebar-collapsed-user'); $loading = $block.find('.block-loading').fadeOut(); + selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; + selectedId = $dropdown.data('selected') || selectedIdDefault; - var updateIssueBoardsIssue = function () { - $loading.removeClass('hidden').fadeIn(); - gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) - .then(function () { - $loading.fadeOut(); - }) - .catch(function () { - $loading.fadeOut(); - }); + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + + // Save current selected user to the DOM + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = $dropdown.data('field-name'); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + + if (currentUserInfo) { + input.value = currentUserInfo.id; + input.dataset.meta = currentUserInfo.name; + } else if (_this.currentUser) { + input.value = _this.currentUser.id; + } + + if ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } + }; + + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); + } + + const getSelectedUserInputs = function() { + return $selectbox + .find(`input[name="${$dropdown.data('field-name')}"]`); + }; + + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; + + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + eventHub.$emit('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; + + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected() + .filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return 'Unassigned'; + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return `${selectedUser.name} + ${otherSelected.length} more`; + } else { + return `${firstUser.name} + ${selectedUsers.length - 1} more`; + } }; $('.assign-to-me-link').on('click', (e) => { e.preventDefault(); $(e.currentTarget).hide(); - const $input = $(`input[name="${$dropdown.data('field-name')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); - }); - $block.on('click', '.js-assign-yourself', function(e) { - e.preventDefault(); - - if ($dropdown.hasClass('js-issue-board-sidebar')) { - gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ - id: _this.currentUser.id, - username: _this.currentUser.username, - name: _this.currentUser.name, - avatar_url: _this.currentUser.avatar_url - })); + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); - updateIssueBoardsIssue(); + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); } else { - return assignTo(_this.currentUser.id); + const $input = $(`input[name="${$dropdown.data('field-name')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); } }); + + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); + assignTo = function(selected) { var data; data = {}; @@ -95,6 +171,7 @@ data[abilityName].assignee_id = selected != null ? selected : null; $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); + return $.ajax({ type: 'PUT', dataType: 'json', @@ -104,7 +181,6 @@ var user; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); - $selectbox.hide(); if (data.assignee) { user = { name: data.assignee.name, @@ -131,51 +207,90 @@ var isAuthorFilter; isAuthorFilter = $('.js-author-search'); return _this.users(term, options, function(users) { - var anyUser, index, j, len, name, obj, showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = j = 0, len = users.length; j < len; index = (j += 1)) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }.bind(this)); + }, + processData: function(term, users, callback) { + let anyUser; + let index; + let j; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = j = 0, len = users.length; j < len; index = (j += 1)) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; } } - if (showNullUser) { - showDivider += 1; - users.unshift({ - beforeDivider: true, - name: 'Unassigned', - id: 0 - }); - } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - beforeDivider: true, - name: name, - id: null - }; - users.unshift(anyUser); + } + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: 'Unassigned', + id: 0 + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = 'Any User'; } + anyUser = { + beforeDivider: true, + name: name, + id: null + }; + users.unshift(anyUser); } + if (showDivider) { - users.splice(showDivider, 0, "divider"); + users.splice(showDivider, 0, 'divider'); } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); + + if (selected.length > 0) { + if ($dropdown.data('dropdown-header')) { + showDivider += 1; + users.splice(showDivider, 0, { + header: $dropdown.data('dropdown-header'), + }); + } + + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); + + users = users.filter(u => selected.indexOf(u.id) === -1); + + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); + + users.splice(showDivider + 1, 0, 'divider'); + } } - }); + } + + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } }, filterable: true, filterRemote: true, @@ -184,33 +299,110 @@ }, selectable: true, fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el) { + toggleLabel: function(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } + + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); if (selected.text) { return selected.text; } else { return selected.name; } } else { + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); return defaultLabel; } }, defaultLabel: defaultLabel, - inputId: 'issue_assignee_id', hidden: function(e) { - $selectbox.hide(); - // display:block overrides the hide-collapse rule - return $value.css('display', ''); + if ($dropdown.hasClass('js-multiselect')) { + eventHub.$emit('sidebar.saveAssignees'); + } + + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); + + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } }, - vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(user, $el, e) { + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('input-meta'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); + + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + const id = parseInt(element.value, 10); + element.remove(); + }); + eventHub.$emit('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + eventHub.$emit('sidebar.addAssignee', user); + + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('field-name'), 0, {}); + } + + // User unselected + eventHub.$emit('sidebar.removeAssignee', user); + } + + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } + var isIssueIndex, isMRIndex, page, selected; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); - selectedId = user.id; + + const isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; + if (selectedId === gon.current_user_id) { $('.assign-to-me-link').hide(); } else { @@ -221,24 +413,10 @@ if ($el.closest('.add-issues-modal').length) { gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - selectedId = user.id; return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); - } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - if (user.id) { - gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ - id: user.id, - username: user.username, - name: user.name, - avatar_url: user.avatar_url - })); - } else { - gl.issueBoards.boardStoreIssueDelete('assignee'); - } - - updateIssueBoardsIssue(); - } else { + } else if (!$dropdown.hasClass('js-multiselect')) { selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); return assignTo(selected); } @@ -248,30 +426,58 @@ }, opened: function(e) { const $el = $(e.currentTarget); + if ($dropdown.hasClass('js-issue-board-sidebar')) { + selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; + } $el.find('.is-active').removeClass('is-active'); - $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active'); + + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } + + if ($selectbox[0]) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else { + highlightSelected(selectedId); + } }, + updateLabel: $dropdown.data('dropdown-title'), renderRow: function(user) { - var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username; + var avatar, img, listClosingTags, listWithName, listWithUserName, username; username = user.username ? "@" + user.username : ""; avatar = user.avatar_url ? user.avatar_url : false; - selected = user.id === parseInt(selectedId, 10) ? "is-active" : ""; + + let selected = user.id === parseInt(selectedId, 10); + + if (this.multiSelect) { + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + + if (field.length) { + selected = true; + } + } + img = ""; if (user.beforeDivider != null) { - "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>"; + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; } else { if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />"; + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; } } - // split into three parts so we can remove the username section if nessesary - listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>"; - listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>"; - listClosingTags = "</a> </li>"; - if (username === '') { - listWithUserName = ''; - } - return listWithName + listWithUserName + listClosingTags; + + return ` + <li data-user-id=${user.id}> + <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> + ${img} + <strong class='dropdown-menu-user-full-name'> + ${user.name} + </strong> + ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} + </a> + </li> + `; } }); }; diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js b/app/assets/javascripts/vue_shared/components/pipelines_table.js index afd8d7acf6b..48a39f18112 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js @@ -10,13 +10,18 @@ export default { pipelines: { type: Array, required: true, - default: () => ([]), }, service: { type: Object, required: true, }, + + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, + }, }, components: { @@ -40,7 +45,9 @@ export default { v-bind:model="model"> <tr is="pipelines-table-row-component" :pipeline="model" - :service="service"></tr> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </template> </tbody> </table> diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index 79806bc7204..fbae85c85f6 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -3,7 +3,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue'; import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions'; import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; import PipelinesStatusComponent from '../../pipelines/components/status'; -import PipelinesStageComponent from '../../pipelines/components/stage'; +import PipelinesStageComponent from '../../pipelines/components/stage.vue'; import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; import CommitComponent from './commit'; @@ -24,6 +24,12 @@ export default { type: Object, required: true, }, + + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, + }, }, components: { @@ -213,7 +219,10 @@ export default { <div class="stage-container dropdown js-mini-pipeline-graph" v-if="pipeline.details.stages.length > 0" v-for="stage in pipeline.details.stages"> - <dropdown-stage :stage="stage"/> + + <dropdown-stage + :stage="stage" + :update-dropdown="updateGraphDropdown"/> </div> </td> diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js new file mode 100644 index 00000000000..f83c4b00761 --- /dev/null +++ b/app/assets/javascripts/vue_shared/translate.js @@ -0,0 +1,42 @@ +import { + __, + n__, + s__, +} from '../locale'; + +export default (Vue) => { + Vue.mixin({ + methods: { + /** + Translates `text` + + @param text The text to be translated + @returns {String} The translated text + **/ + __, + /** + Translate the text with a number + if the number is more than 1 it will use the `pluralText` translation. + This method allows for contexts, see below re. contexts + + @param text Singular text to translate (eg. '%d day') + @param pluralText Plural text to translate (eg. '%d days') + @param count Number to decide which translation to use (eg. 2) + @returns {String} Translated text with the number replaced (eg. '2 days') + **/ + n__, + /** + Translate context based text + Either pass in the context translation like `Context|Text to translate` + or allow for dynamic text by doing passing in the context first & then the text to translate + + @param keyOrContext Can be either the key to translate including the context + (eg. 'Context|Text') or just the context for the translation + (eg. 'Context') + @param key Is the dynamic variable you want to be translated + @returns {String} Translated context based text + **/ + s__, + }, + }); +}; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 7c50b80fd2b..3cd7f81da47 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -159,3 +159,31 @@ a { .fade-in { animation: fadeIn $fade-in-duration 1; } + +@keyframes fadeInHalf { + 0% { + opacity: 0; + } + + 100% { + opacity: 0.5; + } +} + +.fade-in-half { + animation: fadeInHalf $fade-in-duration 1; +} + +@keyframes fadeInFull { + 0% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + +.fade-in-full { + animation: fadeInFull $fade-in-duration 1; +} diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 3f5b78ed445..91c1ebd5a7d 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -93,3 +93,14 @@ align-self: center; } } + +.avatar-counter { + background-color: $gray-darkest; + color: $white-light; + border: 1px solid $border-color; + border-radius: 1em; + font-family: $regular_font; + font-size: 9px; + line-height: 16px; + text-align: center; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 1313ea25c2a..5c9b71a452c 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -251,14 +251,16 @@ } .dropdown-header { - color: $gl-text-color; + color: $gl-text-color-secondary; font-size: 13px; - font-weight: 600; line-height: 22px; - text-transform: capitalize; padding: 0 16px; } + &.capitalize-header .dropdown-header { + text-transform: capitalize; + } + .separator + .dropdown-header { padding-top: 2px; } @@ -337,8 +339,8 @@ .dropdown-menu-user { .avatar { float: left; - width: 30px; - height: 30px; + width: 2 * $gl-padding; + height: 2 * $gl-padding; margin: 0 10px 0 0; } } @@ -381,6 +383,7 @@ .dropdown-menu-selectable { a { padding-left: 26px; + position: relative; &.is-indeterminate, &.is-active { @@ -390,7 +393,8 @@ &::before { position: absolute; left: 6px; - top: 6px; + top: 50%; + transform: translateY(-50%); font: normal normal normal 14px/1 FontAwesome; font-size: inherit; text-rendering: auto; @@ -405,6 +409,9 @@ &.is-active::before { content: "\f00c"; + position: absolute; + top: 50%; + transform: translateY(-50%); } } } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index c197bf6b9f5..1dd0e5ab581 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -162,6 +162,18 @@ &.code { padding: 0; } + + .list-inline.previews { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; + align-items: baseline; + + .preview { + padding: $gl-padding; + } + } } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 0692f65043b..e624d0d951e 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -114,11 +114,21 @@ padding-right: 8px; .fa-close { - color: $gl-text-color-disabled; + color: $gl-text-color-secondary; } &:hover .fa-close { - color: $gl-text-color-secondary; + color: $gl-text-color; + } + + &.inverted { + .fa-close { + color: $gl-text-color-secondary-inverted; + } + + &:hover .fa-close { + color: $gl-text-color-inverted; + } } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 15dc0aa6a52..c9a25946ffd 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -255,6 +255,7 @@ ul.controls { .avatar-inline { margin-left: 0; margin-right: 0; + margin-bottom: 0; } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 49741c963df..08bcb582613 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -101,6 +101,8 @@ $gl-font-size: 14px; $gl-text-color: rgba(0, 0, 0, .85); $gl-text-color-secondary: rgba(0, 0, 0, .55); $gl-text-color-disabled: rgba(0, 0, 0, .35); +$gl-text-color-inverted: rgba(255, 255, 255, 1.0); +$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85); $gl-text-green: $green-600; $gl-text-red: $red-500; $gl-text-orange: $orange-600; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 0be1c215959..68d7ab4bf84 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -207,8 +207,13 @@ margin-bottom: 5px; } - &.is-active { + &.is-active, + &.is-active .card-assignee:hover a { background-color: $row-hover; + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $row-hover; + } } .label { @@ -224,7 +229,7 @@ } .card-title { - margin: 0; + margin: 0 30px 0 0; font-size: 1em; line-height: inherit; @@ -240,10 +245,69 @@ min-height: 20px; .card-assignee { - margin-left: auto; - margin-right: 5px; - padding-left: 10px; + display: flex; + justify-content: flex-end; + position: absolute; + right: 15px; height: 20px; + width: 20px; + + .avatar-counter { + display: none; + vertical-align: middle; + min-width: 20px; + line-height: 19px; + height: 20px; + padding-left: 2px; + padding-right: 2px; + border-radius: 2em; + } + + img { + vertical-align: top; + } + + a { + position: relative; + margin-left: -15px; + } + + a:nth-child(1) { + z-index: 3; + } + + a:nth-child(2) { + z-index: 2; + } + + a:nth-child(3) { + z-index: 1; + } + + a:nth-child(4) { + display: none; + } + + &:hover { + .avatar-counter { + display: inline-block; + } + + a { + position: static; + background-color: $white-light; + transition: background-color 0s; + margin-left: auto; + + &:nth-child(4) { + display: block; + } + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $white-light; + } + } + } } .avatar { diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 403724cd68a..d29944207c5 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -3,6 +3,25 @@ margin: 24px auto 0; position: relative; + .landing { + margin-top: 10px; + + .inner-content { + white-space: normal; + + h4, + p { + margin: 7px 0 0; + max-width: 480px; + padding: 0 $gl-padding; + + @media (max-width: $screen-sm-min) { + margin: 0 auto; + } + } + } + } + .col-headers { ul { margin: 0; @@ -175,7 +194,7 @@ } .stage-nav-item { - display: block; + display: flex; line-height: 65px; border-top: 1px solid transparent; border-bottom: 1px solid transparent; @@ -209,14 +228,10 @@ } .stage-nav-item-cell { - float: left; - - &.stage-name { - width: 65%; - } - &.stage-median { - width: 35%; + margin-left: auto; + margin-right: $gl-padding; + min-width: calc(35% - #{$gl-padding}); } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 1b4694377b3..77f2638683a 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -425,12 +425,6 @@ float: right; } -.diffs { - .content-block { - border-bottom: none; - } -} - .files-changed { border-bottom: none; } @@ -576,14 +570,7 @@ .diff-comments-more-count, .diff-notes-collapse { - background-color: $gray-darkest; - color: $white-light; - border: 1px solid $white-light; - border-radius: 1em; - font-family: $regular_font; - font-size: 9px; - line-height: 17px; - text-align: center; + @extend .avatar-counter; } .diff-notes-collapse { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 72e7d42858d..026d35295d7 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -157,7 +157,8 @@ .prometheus-graph { text { - fill: $stat-graph-axis-fill; + fill: $gl-text-color; + stroke-width: 0; } .label-axis-text, @@ -210,27 +211,33 @@ .rect-text-metric { fill: $white-light; stroke-width: 1; - stroke: $black; + stroke: $gray-darkest; } .rect-axis-text { fill: $white-light; } -.text-metric, -.text-median-metric, -.text-metric-usage, -.text-metric-date { - fill: $black; +.text-metric { + font-weight: 600; } -.text-metric-date { - font-weight: 200; +.selected-metric-line { + stroke: $gl-gray-dark; + stroke-width: 1; } -.selected-metric-line { +.deployment-line { stroke: $black; - stroke-width: 1; + stroke-width: 2; +} + +.deploy-info-text { + dominant-baseline: text-before-edge; +} + +.text-metric-bold { + font-weight: 600; } .prometheus-state { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 97fab513b01..c4210ffd823 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -6,7 +6,13 @@ } .limit-container-width { - .detail-page-header { + .detail-page-header, + .page-content-header, + .commit-box, + .info-well, + .notes, + .commit-ci-menu, + .files-changed { @extend .fixed-width-container; } @@ -36,8 +42,7 @@ } .diffs { - .mr-version-controls, - .files-changed { + .mr-version-controls { @extend .fixed-width-container; } } @@ -90,10 +95,15 @@ } .right-sidebar { - a { + a, + .btn-link { color: inherit; } + .btn-link { + outline: none; + } + .issuable-header-text { margin-top: 7px; } @@ -210,6 +220,10 @@ } } + .assign-yourself .btn-link { + padding-left: 0; + } + .light { font-weight: normal; } @@ -234,6 +248,10 @@ margin-left: 0; } + .assignee .user-list .avatar { + margin: 0; + } + .username { display: block; margin-top: 4px; @@ -296,6 +314,10 @@ margin-top: 0; } + .sidebar-avatar-counter { + padding-top: 2px; + } + .todo-undone { color: $gl-link-color; } @@ -304,10 +326,15 @@ display: none; } - .avatar:hover { + .avatar:hover, + .avatar-counter:hover { border-color: $issuable-sidebar-color; } + .avatar-counter:hover { + color: $issuable-sidebar-color; + } + .btn-clipboard { border: none; color: $issuable-sidebar-color; @@ -317,6 +344,17 @@ color: $gl-text-color; } } + + &.multiple-users { + display: flex; + justify-content: center; + } + } + + .sidebar-avatar-counter { + width: 24px; + height: 24px; + border-radius: 12px; } .sidebar-collapsed-user { @@ -327,6 +365,37 @@ .issuable-header-btn { display: none; } + + .multiple-users { + height: 24px; + margin-bottom: 17px; + margin-top: 4px; + padding-bottom: 4px; + + .btn-link { + padding: 0; + border: 0; + + .avatar { + margin: 0; + } + } + + .btn-link:first-child { + position: absolute; + left: 10px; + z-index: 1; + } + + .btn-link:last-child { + position: absolute; + right: 10px; + + &:hover { + text-decoration: none; + } + } + } } a { @@ -378,6 +447,12 @@ margin: -5px; } + +.user-list { + display: flex; + flex-wrap: wrap; +} + .participants-author { display: inline-block; padding: 5px; @@ -395,13 +470,39 @@ } } -.participants-more { +.user-item { + display: inline-block; + padding: 5px; + flex-basis: 20%; + + .user-link { + display: inline-block; + } +} + +.participants-more, +.user-list-more { margin-top: 5px; margin-left: 5px; - a { + a, + .btn-link { color: $gl-text-color-secondary; } + + .btn-link { + outline: none; + padding: 0; + } + + .btn-link:hover { + @extend a:hover; + text-decoration: none; + } + + .btn-link:focus { + text-decoration: none; + } } .issuable-form-padding-top { @@ -494,6 +595,19 @@ } } +.issuable-list li, +.issue-info-container .controls { + .avatar-counter { + display: inline-block; + vertical-align: middle; + min-width: 16px; + line-height: 14px; + height: 16px; + padding-left: 2px; + padding-right: 2px; + } +} + .time_tracker { padding-bottom: 0; border-bottom: 0; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 2aa52986e0a..ad3b6e0344b 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -18,6 +18,15 @@ } } +.issue-realtime-pre-pulse { + opacity: 0; +} + +.issue-realtime-trigger-pulse { + transition: opacity $fade-in-duration linear; + opacity: 1; +} + .check-all-holder { line-height: 36px; float: left; @@ -161,3 +170,86 @@ ul.related-merge-requests > li { .recaptcha { margin-bottom: 30px; } + +.new-branch-col { + padding-top: 10px; +} + +.create-mr-dropdown-wrap { + .btn-group:not(.hide) { + display: flex; + } + + .js-create-merge-request { + flex-grow: 1; + flex-shrink: 0; + } + + .dropdown-menu { + width: 300px; + opacity: 1; + visibility: visible; + transform: translateY(0); + display: none; + } + + .dropdown-toggle { + .fa-caret-down { + pointer-events: none; + margin-left: 0; + color: inherit; + margin-left: 0; + } + } + + li:not(.divider) { + padding: 6px; + cursor: pointer; + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + } + + &.droplab-item-selected { + .icon-container { + i { + visibility: visible; + } + } + } + + .icon-container { + float: left; + padding-left: 6px; + + i { + visibility: hidden; + } + } + + .description { + padding-left: 30px; + font-size: 13px; + + strong { + display: block; + font-weight: 600; + } + } + } +} + +@media (min-width: $screen-sm-min) { + .new-branch-col { + padding-top: 0; + text-align: right; + } + + .create-mr-dropdown-wrap { + .btn-group:not(.hide) { + display: inline-block; + } + } +} diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index e1ef0b029a5..c10588ac58e 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -116,7 +116,7 @@ } .manage-labels-list { - > li:not(.empty-message) { + > li:not(.empty-message):not(.is-not-draggable) { background-color: $white-light; cursor: move; cursor: -webkit-grab; diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index be7193bae04..8dbac76e30a 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -133,3 +133,55 @@ right: 160px; } } + +.flex-project-members-panel { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + @media (max-width: $screen-sm-min) { + display: block; + + .flex-project-title { + vertical-align: top; + display: inline-block; + max-width: 90%; + } + } + + .flex-project-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .badge { + height: 17px; + line-height: 16px; + margin-right: 5px; + padding-top: 1px; + padding-bottom: 1px; + } + + .flex-project-members-form { + flex-wrap: nowrap; + white-space: nowrap; + margin-left: auto; + } +} + +.panel { + .panel-heading { + .badge { + margin-top: 0; + } + + @media (max-width: $screen-sm-min) { + .badge { + margin-right: 0; + margin-left: 0; + } + } + } +}
\ No newline at end of file diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6a419384a34..72660113e3c 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -482,6 +482,10 @@ } } +.target-branch-select-dropdown-container { + position: relative; +} + .assign-to-me-link { padding-left: 12px; white-space: nowrap; @@ -511,7 +515,6 @@ .mr-version-controls { background: $gray-light; - border-bottom: 1px solid $border-color; color: $gl-text-color; .mr-version-menus-container { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 7cf74502a3a..69c328d09ff 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -57,6 +57,25 @@ ul.notes { position: relative; border-bottom: 1px solid $white-normal; + &.being-posted { + pointer-events: none; + opacity: 0.5; + + .dummy-avatar { + display: inline-block; + height: 40px; + width: 40px; + border-radius: 50%; + background-color: $kdb-border; + border: 1px solid darken($kdb-border, 25%); + } + + .note-headline-light, + .fa-spinner { + margin-left: 3px; + } + } + &.note-discussion { &.timeline-entry { padding: 14px 10px; @@ -67,7 +86,7 @@ ul.notes { } } - &.is-editting { + &.is-editing { .note-header, .note-text, .edited-text { @@ -111,12 +130,6 @@ ul.notes { } .note-header { - padding-bottom: 8px; - padding-right: 20px; - - @media (min-width: $screen-sm-min) { - padding-right: 0; - } @media (max-width: $screen-xs-min) { .inline { @@ -365,10 +378,15 @@ ul.notes { .note-header { display: flex; justify-content: space-between; + + @media (max-width: $screen-xs-max) { + flex-flow: row wrap; + } } .note-header-info { min-width: 0; + padding-bottom: 5px; } .note-headline-light { @@ -416,6 +434,11 @@ ul.notes { margin-left: 10px; color: $gray-darkest; + @media (max-width: $screen-xs-max) { + float: none; + margin-left: 0; + } + .note-action-button { margin-left: 8px; } @@ -687,6 +710,10 @@ ul.notes { } } +.discussion-notes .flash-container { + margin-bottom: 0; +} + // Merge request notes in diffs .diff-file { // Diff is side by side diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a4fe652b52f..530a6f3c6a1 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -273,6 +273,7 @@ .stage-container { display: inline-block; position: relative; + vertical-align: middle; height: 22px; margin: 3px 6px 3px 0; @@ -316,6 +317,32 @@ } } +.build-failures { + .build-state { + padding: 20px 2px; + + .build-name { + float: right; + font-weight: 500; + } + + .ci-status-icon-failed svg { + vertical-align: middle; + } + + .stage { + color: $gl-text-color-secondary; + font-weight: 500; + vertical-align: middle; + } + } + + .build-log { + border: none; + line-height: initial; + } +} + // Pipeline graph .pipeline-graph { width: 100%; @@ -781,16 +808,11 @@ } .scrollable-menu { + padding: 0; max-height: 245px; overflow: auto; } - // Loading icon - .builds-dropdown-loading { - margin: 0 auto; - width: 20px; - } - // Action icon on the right a.ci-action-icon-wrapper { color: $action-icon-color; @@ -893,30 +915,29 @@ * Top arrow in the dropdown in the mini pipeline graph */ .mini-pipeline-graph-dropdown-menu { - .arrow-up { - &::before, - &::after { - content: ''; - display: inline-block; - position: absolute; - width: 0; - height: 0; - border-color: transparent; - border-style: solid; - top: -6px; - left: 2px; - border-width: 0 5px 6px; - } - &::before { - border-width: 0 5px 5px; - border-bottom-color: $border-color; - } + &::before, + &::after { + content: ''; + display: inline-block; + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + top: -6px; + left: 2px; + border-width: 0 5px 6px; + } - &::after { - margin-top: 1px; - border-bottom-color: $white-light; - } + &::before { + border-width: 0 5px 5px; + border-bottom-color: $border-color; + } + + &::after { + margin-top: 1px; + border-bottom-color: $white-light; } } diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index a39815319f3..de652a79369 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -54,8 +54,9 @@ background-color: $white-light; &:hover { - border-color: $white-dark; + border-color: $white-normal; background-color: $gray-light; + border-top: 1px solid transparent; .todo-avatar, .todo-item { diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 04ff2d52b91..b64b89485f7 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -71,7 +71,6 @@ .nav-controls { width: auto; min-width: 50%; - white-space: nowrap; } } diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 643993d035e..152d7baad49 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -133,6 +133,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :signup_enabled, :sentry_dsn, :sentry_enabled, + :clientside_sentry_dsn, + :clientside_sentry_enabled, :send_user_confirmation_email, :shared_runners_enabled, :shared_runners_text, diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index 37a1a23178e..4c3d336b3af 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -16,6 +16,8 @@ class Admin::ServicesController < Admin::ApplicationController def update if service.update_attributes(service_params[:service]) + PropagateServiceTemplateWorker.perform_async(service.id) if service.active? + redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' else diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e48f0963ef4..65a1f640a76 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,6 +21,8 @@ class ApplicationController < ActionController::Base before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? + around_action :set_locale + protect_from_forgery with: :exception helper_method :can?, :current_application_settings @@ -56,7 +58,7 @@ class ApplicationController < ActionController::Base if current_user not_found else - redirect_to new_user_session_path + authenticate_user! end end @@ -269,4 +271,12 @@ class ApplicationController < ActionController::Base def u2f_app_id request.base_url end + + def set_locale + Gitlab::I18n.set_locale(current_user) + + yield + ensure + Gitlab::I18n.reset_locale + end end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 3ccf2a9ce33..b199f18da1e 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -66,6 +66,7 @@ module IssuableActions :milestone_id, :state_event, :subscription_event, + assignee_ids: [], label_ids: [], add_label_ids: [], remove_label_ids: [] diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c8a501d7319..6df2c068745 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -43,7 +43,7 @@ module IssuableCollections end def issues_collection - issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) + issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace) end def merge_requests_collection diff --git a/app/controllers/concerns/markdown_preview.rb b/app/controllers/concerns/markdown_preview.rb deleted file mode 100644 index 40eff267348..00000000000 --- a/app/controllers/concerns/markdown_preview.rb +++ /dev/null @@ -1,19 +0,0 @@ -module MarkdownPreview - private - - def render_markdown_preview(text, markdown_context = {}) - render json: { - body: view_context.markdown(text, markdown_context), - references: { - users: preview_referenced_users(text) - } - } - end - - def preview_referenced_users(text) - extractor = Gitlab::ReferenceExtractor.new(@project, current_user) - extractor.analyze(text, author: current_user) - - extractor.users.map(&:username) - end -end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb new file mode 100644 index 00000000000..3e2a0fe4f8b --- /dev/null +++ b/app/controllers/concerns/milestone_actions.rb @@ -0,0 +1,53 @@ +module MilestoneActions + extend ActiveSupport::Concern + + def merge_requests + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_merge_requests_tab", { + merge_requests: @milestone.merge_requests, + show_project_name: true + }) + end + end + end + + def participants + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_participants_tab", { + users: @milestone.participants + }) + end + end + end + + def labels + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_labels_tab", { + labels: @milestone.labels + }) + end + end + end + + private + + def tabs_json(partial, data = {}) + { + html: view_to_html_string(partial, data) + } + end + + def milestone_redirect_path + if @project + namespace_project_milestone_path(@project.namespace, @project, @milestone) + else + group_milestone_path(@group, @milestone.safe_title, title: @milestone.title) + end + end +end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index c32038d07bf..a57d9e6e6c0 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -65,6 +65,15 @@ module NotesActions private + def note_html(note) + render_to_string( + "shared/notes/_note", + layout: false, + formats: [:html], + locals: { note: note } + ) + end + def note_json(note) attrs = { commands_changes: note.commands_changes @@ -98,6 +107,41 @@ module NotesActions attrs end + def diff_discussion_html(discussion) + return unless discussion.diff_discussion? + + if params[:view] == 'parallel' + template = "discussions/_parallel_diff_discussion" + locals = + if params[:line_type] == 'old' + { discussions_left: [discussion], discussions_right: nil } + else + { discussions_left: nil, discussions_right: [discussion] } + end + else + template = "discussions/_diff_discussion" + locals = { discussions: [discussion] } + end + + render_to_string( + template, + layout: false, + formats: [:html], + locals: locals + ) + end + + def discussion_html(discussion) + return if discussion.individual_note? + + render_to_string( + "discussions/_discussion", + layout: false, + formats: [:html], + locals: { discussion: discussion } + ) + end + def authorize_admin_note! return access_denied! unless can?(current_user, :admin_note, note) end diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb new file mode 100644 index 00000000000..d4ab6782444 --- /dev/null +++ b/app/controllers/concerns/routable_actions.rb @@ -0,0 +1,38 @@ +module RoutableActions + extend ActiveSupport::Concern + + def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil) + routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) + + if routable_authorized?(routable_klass, routable, extra_authorization_proc) + ensure_canonical_path(routable, requested_full_path) + routable + else + route_not_found + nil + end + end + + def routable_authorized?(routable_klass, routable, extra_authorization_proc) + action = :"read_#{routable_klass.to_s.underscore}" + return false unless can?(current_user, action, routable) + + if extra_authorization_proc + extra_authorization_proc.call(routable) + else + true + end + end + + def ensure_canonical_path(routable, requested_path) + return unless request.get? + + canonical_path = routable.full_path + if canonical_path != requested_path + if canonical_path.casecmp(requested_path) != 0 + flash[:notice] = "Project '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." + end + redirect_to request.original_url.sub(requested_path, canonical_path) + end + end +end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb new file mode 100644 index 00000000000..dec2e27335a --- /dev/null +++ b/app/controllers/concerns/uploads_actions.rb @@ -0,0 +1,27 @@ +module UploadsActions + def create + link_to_file = UploadService.new(model, params[:file], uploader_class).execute + + respond_to do |format| + if link_to_file + format.json do + render json: { link: link_to_file } + end + else + format.json do + render json: 'Invalid file.', status: :unprocessable_entity + end + end + end + end + + def show + return render_404 unless uploader.exists? + + disposition = uploader.image_or_video? ? 'inline' : 'attachment' + + expires_in 0.seconds, must_revalidate: true, private: true + + send_file uploader.file.path, disposition: disposition + end +end diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb index d5031da867a..dd1d46a68c7 100644 --- a/app/controllers/dashboard/labels_controller.rb +++ b/app/controllers/dashboard/labels_controller.rb @@ -3,7 +3,7 @@ class Dashboard::LabelsController < Dashboard::ApplicationController labels = LabelsFinder.new(current_user).execute respond_to do |format| - format.json { render json: labels.as_json(only: [:id, :title, :color]) } + format.json { render json: LabelSerializer.new.represent_appearance(labels) } end end end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 29ffaeb19c1..afffb813b44 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -1,4 +1,6 @@ class Groups::ApplicationController < ApplicationController + include RoutableActions + layout 'group' skip_before_action :authenticate_user! @@ -7,29 +9,17 @@ class Groups::ApplicationController < ApplicationController private def group - unless @group - id = params[:group_id] || params[:id] - @group = Group.find_by_full_path(id) - @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute - - unless @group && can?(current_user, :read_group, @group) - @group = nil - - if current_user.nil? - authenticate_user! - else - render_404 - end - end - end - - @group + @group ||= find_routable!(Group, params[:group_id] || params[:id]) end def group_projects @projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute end + def group_merge_requests + @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute + end + def authorize_admin_group! unless can?(current_user, :admin_group, group) return render_404 diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index facb25525b5..3fa0516fb0c 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -15,7 +15,7 @@ class Groups::LabelsController < Groups::ApplicationController format.json do available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute - render json: available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(available_labels) end end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 43102596201..e52fa766044 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -1,6 +1,8 @@ class Groups::MilestonesController < Groups::ApplicationController + include MilestoneActions + before_action :group_projects - before_action :milestone, only: [:show, :update] + before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels] before_action :authorize_admin_milestones!, only: [:new, :create, :update] def index diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 593001e6396..46c3ff10694 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -12,8 +12,8 @@ class GroupsController < Groups::ApplicationController before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects] before_action :authorize_create_group!, only: [:new, :create] - # Load group projects before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] + before_action :group_merge_requests, only: [:merge_requests] before_action :event_filter, only: [:activity] before_action :user_actions, only: [:show, :subgroups] diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 58d50ad647b..2a8c8ca4bad 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -67,7 +67,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def omniauth_error @provider = params[:provider] @error = params[:error] - render 'errors/omniauth_error', layout: "errors", status: 422 + render 'errors/omniauth_error', layout: "oauth_error", status: 422 end def cas3 diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 987b95e89b9..57e23cea00e 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -85,7 +85,8 @@ class ProfilesController < Profiles::ApplicationController :twitter, :username, :website_url, - :organization + :organization, + :preferred_language ) end end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index e2f81b09adc..b4b0dfc3eb8 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -1,5 +1,8 @@ class Projects::ApplicationController < ApplicationController + include RoutableActions + skip_before_action :authenticate_user! + before_action :redirect_git_extension before_action :project before_action :repository layout 'project' @@ -8,40 +11,22 @@ class Projects::ApplicationController < ApplicationController private + def redirect_git_extension + # Redirect from + # localhost/group/project.git + # to + # localhost/group/project + # + redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git' + end + def project - unless @project - namespace = params[:namespace_id] - id = params[:project_id] || params[:id] - - # Redirect from - # localhost/group/project.git - # to - # localhost/group/project - # - if params[:format] == 'git' - redirect_to request.original_url.gsub(/\.git\/?\Z/, '') - return - end - - project_path = "#{namespace}/#{id}" - @project = Project.find_by_full_path(project_path) - - if can?(current_user, :read_project, @project) && !@project.pending_delete? - if @project.path_with_namespace != project_path - redirect_to request.original_url.gsub(project_path, @project.path_with_namespace) - end - else - @project = nil - - if current_user.nil? - authenticate_user! - else - render_404 - end - end - end + return @project if @project - @project + path = File.join(params[:namespace_id], params[:project_id] || params[:id]) + auth_proc = ->(project) { !project.pending_delete? } + + @project = find_routable!(Project, path, extra_authorization_proc: auth_proc) end def repository @@ -89,4 +74,8 @@ class Projects::ApplicationController < ApplicationController def builds_enabled return render_404 unless @project.feature_available?(:builds, current_user) end + + def require_pages_enabled! + not_found unless Gitlab.config.pages.enabled + end end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 59222637961..1224e9503c9 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,11 +1,13 @@ class Projects::ArtifactsController < Projects::ApplicationController include ExtractsPath + include RendersBlob layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path before_action :validate_artifacts! + before_action :set_path_and_entry, only: [:file, :raw] def download if artifacts_file.file_storage? @@ -16,22 +18,32 @@ class Projects::ArtifactsController < Projects::ApplicationController end def browse - directory = params[:path] ? "#{params[:path]}/" : '' + @path = params[:path] + directory = @path ? "#{@path}/" : '' @entry = build.artifacts_metadata_entry(directory) render_404 unless @entry.exists? end def file - entry = build.artifacts_metadata_entry(params[:path]) + blob = @entry.blob + override_max_blob_size(blob) - if entry.exists? - send_artifacts_entry(build, entry) - else - render_404 + respond_to do |format| + format.html do + render 'file' + end + + format.json do + render_blob_json(blob) + end end end + def raw + send_artifacts_entry(build, @entry) + end + def keep build.keep_artifacts! redirect_to namespace_project_build_path(project.namespace, project, build) @@ -60,7 +72,10 @@ class Projects::ArtifactsController < Projects::ApplicationController end def build - @build ||= build_from_id || build_from_ref + @build ||= begin + build = build_from_id || build_from_ref + build&.present(current_user: current_user) + end end def build_from_id @@ -77,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController def artifacts_file @artifacts_file ||= build.artifacts_file end + + def set_path_and_entry + @path = params[:path] + @entry = build.artifacts_metadata_entry(@path) + + render_404 unless @entry.exists? + end end diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb index 28c9646910d..da9b789d617 100644 --- a/app/controllers/projects/boards/issues_controller.rb +++ b/app/controllers/projects/boards/issues_controller.rb @@ -82,7 +82,7 @@ module Projects labels: true, only: [:id, :iid, :title, :confidential, :due_date, :relative_position], include: { - assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, milestone: { only: [:id, :title] } }, user: current_user diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 840405f38cb..f0f031303d8 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -46,20 +46,28 @@ class Projects::BranchesController < Projects::ApplicationController SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue end - if result[:status] == :success - @branch = result[:branch] - - if redirect_to_autodeploy - redirect_to( - url_to_autodeploy_setup(project, branch_name), - notice: view_context.autodeploy_flash_notice(branch_name)) - else - redirect_to namespace_project_tree_path(@project.namespace, @project, - @branch.name) + respond_to do |format| + format.html do + if result[:status] == :success + if redirect_to_autodeploy + redirect_to url_to_autodeploy_setup(project, branch_name), + notice: view_context.autodeploy_flash_notice(branch_name) + else + redirect_to namespace_project_tree_path(@project.namespace, @project, branch_name) + end + else + @error = result[:message] + render action: 'new' + end + end + + format.json do + if result[:status] == :success + render json: { name: branch_name, url: namespace_project_tree_url(@project.namespace, @project, branch_name) } + else + render json: result[:messsage], status: :unprocessable_entity + end end - else - @error = result[:message] - render action: 'new' end end diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index d0c44e297e3..f27089b8590 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController layout "project_settings" def index - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json do + render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json + end + end end def new @@ -19,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController @key = DeployKey.new(deploy_key_params.merge(user: current_user)) unless @key.valid? && @project.deploy_keys << @key - flash[:alert] = @key.errors.full_messages.join(', ').html_safe + flash[:alert] = @key.errors.full_messages.join(', ').html_safe end redirect_to_repository_settings(@project) end @@ -27,7 +32,10 @@ class Projects::DeployKeysController < Projects::ApplicationController def enable Projects::EnableDeployKeyService.new(@project, current_user, params).execute - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end def disable @@ -35,7 +43,11 @@ class Projects::DeployKeysController < Projects::ApplicationController return render_404 unless deploy_key_project deploy_key_project.destroy! - redirect_to_repository_settings(@project) + + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end protected diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb new file mode 100644 index 00000000000..c319671456d --- /dev/null +++ b/app/controllers/projects/deployments_controller.rb @@ -0,0 +1,18 @@ +class Projects::DeploymentsController < Projects::ApplicationController + before_action :authorize_read_environment! + before_action :authorize_read_deployment! + + def index + deployments = environment.deployments.reorder(created_at: :desc) + deployments = deployments.where('created_at > ?', params[:after].to_time) if params[:after]&.to_time + + render json: { deployments: DeploymentSerializer.new(user: @current_user, project: project) + .represent_concise(deployments) } + end + + private + + def environment + @environment ||= project.environments.find(params[:environment_id]) + end +end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 10adddb4636..9e4edcae101 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -59,7 +59,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController def render_ok set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.git_http_ok(repository, user, action_name) + render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name) end def render_http_not_allowed diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index cbf67137261..bcd23d61519 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, - :related_branches, :can_create_branch, :rendered_title] + :related_branches, :can_create_branch, :rendered_title, :create_merge_request] # Allow read any issue before_action :authorize_read_issue!, only: [:show, :rendered_title] @@ -22,6 +22,9 @@ class Projects::IssuesController < Projects::ApplicationController # Allow modify issue before_action :authorize_update_issue!, only: [:edit, :update] + # Allow create a new branch and empty WIP merge request from current issue + before_action :authorize_create_merge_request!, only: [:create_merge_request] + respond_to :html def index @@ -64,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController def new params[:issue] ||= ActionController::Parameters.new( - assignee_id: "" + assignee_ids: "" ) build_params = issue_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], @@ -147,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController if @issue.valid? render json: @issue.to_json(methods: [:task_status, :task_status_short], include: { milestone: {}, - assignee: { only: [:name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, labels: { methods: :text_color } }) else render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity @@ -191,14 +194,33 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.json do - render json: { can_create_branch: can_create } + render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? } end end end def rendered_title Gitlab::PollingInterval.set_header(response, interval: 3_000) - render json: { title: view_context.markdown_field(@issue, :title) } + + render json: { + title: view_context.markdown_field(@issue, :title), + title_text: @issue.title, + description: view_context.markdown_field(@issue, :description), + description_text: @issue.description, + task_status: @issue.task_status, + issue_number: @issue.iid, + updated_at: @issue.updated_at, + } + end + + def create_merge_request + result = MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute + + if result[:status] == :success + render json: MergeRequestCreateSerializer.new.represent(result[:merge_request]) + else + render json: result[:messsage], status: :unprocessable_entity + end end protected @@ -224,6 +246,10 @@ class Projects::IssuesController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_issue, @project) end + def authorize_create_merge_request! + return render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) + end + def module_enabled return render_404 unless @project.feature_available?(:issues, current_user) && @project.default_issues_tracker? end @@ -258,7 +284,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, - :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] + :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [], ) end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 2f55ba4e700..71bfb7163da 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -19,7 +19,7 @@ class Projects::LabelsController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: @available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(@available_labels) end end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 09dc8b38229..a63b7ff0bed 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -120,7 +120,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController define_diff_comment_vars else build_merge_request - @diffs = @merge_request.diffs(diff_options) + @compare = @merge_request + @diffs = @compare.diffs(diff_options) @diff_notes_disabled = true end @@ -584,12 +585,14 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - @diffs = + @compare = if @start_sha - @merge_request_diff.compare_with(@start_sha).diffs(diff_options) + @merge_request_diff.compare_with(@start_sha) else - @merge_request_diff.diffs(diff_options) + @merge_request_diff end + + @diffs = @compare.diffs(diff_options) end def define_diff_comment_vars @@ -598,11 +601,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController noteable_id: @merge_request.id } - @diff_notes_disabled = !@merge_request_diff.latest? || @start_sha + @diff_notes_disabled = false @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? - @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@merge_request_diff.diff_refs) + @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs) @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index d0dd524c484..c56bce19eee 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -1,12 +1,14 @@ class Projects::MilestonesController < Projects::ApplicationController + include MilestoneActions + before_action :module_enabled - before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests] + before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests, :merge_requests, :participants, :labels] # Allow read any milestone before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show] + before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] respond_to :html diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 37f51b2ebe3..41a13f6f577 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -62,50 +62,6 @@ class Projects::NotesController < Projects::ApplicationController end alias_method :awardable, :note - def note_html(note) - render_to_string( - "shared/notes/_note", - layout: false, - formats: [:html], - locals: { note: note } - ) - end - - def discussion_html(discussion) - return if discussion.individual_note? - - render_to_string( - "discussions/_discussion", - layout: false, - formats: [:html], - locals: { discussion: discussion } - ) - end - - def diff_discussion_html(discussion) - return unless discussion.diff_discussion? - - if params[:view] == 'parallel' - template = "discussions/_parallel_diff_discussion" - locals = - if params[:line_type] == 'old' - { discussions_left: [discussion], discussions_right: nil } - else - { discussions_left: nil, discussions_right: [discussion] } - end - else - template = "discussions/_diff_discussion" - locals = { discussions: [discussion] } - end - - render_to_string( - template, - layout: false, - formats: [:html], - locals: locals - ) - end - def finder_params params.merge(last_fetched_at: last_fetched_at) end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index fbd18b68141..93b2c180810 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -1,6 +1,7 @@ class Projects::PagesController < Projects::ApplicationController layout 'project_settings' + before_action :require_pages_enabled! before_action :authorize_read_pages!, only: [:show] before_action :authorize_update_pages!, except: [:show] diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index b8c253f6ae3..3a93977fd27 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -1,6 +1,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController layout 'project_settings' + before_action :require_pages_enabled! before_action :authorize_update_pages!, except: [:show] before_action :domain, only: [:show, :destroy] diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 1780cc0233c..5cb2e428201 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -1,27 +1,31 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :pipeline, except: [:index, :new, :create, :charts] - before_action :commit, only: [:show, :builds] + before_action :commit, only: [:show, :builds, :failures] before_action :authorize_read_pipeline! before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :builds_enabled, only: :charts + wrap_parameters Ci::Pipeline + + POLLING_INTERVAL = 10_000 + def index @scope = params[:scope] @pipelines = PipelinesFinder - .new(project) - .execute(scope: @scope) + .new(project, scope: @scope) + .execute .page(params[:page]) .per(30) @running_count = PipelinesFinder - .new(project).execute(scope: 'running').count + .new(project, scope: 'running').execute.count @pending_count = PipelinesFinder - .new(project).execute(scope: 'pending').count + .new(project, scope: 'pending').execute.count @finished_count = PipelinesFinder - .new(project).execute(scope: 'finished').count + .new(project, scope: 'finished').execute.count @pipelines_count = PipelinesFinder .new(project).execute.count @@ -29,7 +33,7 @@ class Projects::PipelinesController < Projects::ApplicationController respond_to do |format| format.html format.json do - Gitlab::PollingInterval.set_header(response, interval: 10_000) + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) render json: { pipelines: PipelineSerializer @@ -55,22 +59,36 @@ class Projects::PipelinesController < Projects::ApplicationController @pipeline = Ci::CreatePipelineService .new(project, current_user, create_params) .execute(ignore_skip_ci: true, save_on_errors: false) - unless @pipeline.persisted? + + if @pipeline.persisted? + redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) + else render 'new' - return end - - redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) end def show + respond_to do |format| + format.html + format.json do + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) + + render json: PipelineSerializer + .new(project: @project, user: @current_user) + .represent(@pipeline, grouped: true) + end + end end def builds - respond_to do |format| - format.html do - render 'show' - end + render_show + end + + def failures + if @pipeline.statuses.latest.failed.present? + render_show + else + redirect_to pipeline_path(@pipeline) end end @@ -92,13 +110,25 @@ class Projects::PipelinesController < Projects::ApplicationController def retry pipeline.retry_failed(current_user) - redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + respond_to do |format| + format.html do + redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + end + + format.json { head :no_content } + end end def cancel pipeline.cancel_running - redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + respond_to do |format| + format.html do + redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + end + + format.json { head :no_content } + end end def charts @@ -111,6 +141,14 @@ class Projects::PipelinesController < Projects::ApplicationController private + def render_show + respond_to do |format| + format.html do + render 'show' + end + end + end + def create_params params.require(:pipeline).permit(:ref) end diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index a0b08ad130f..a02cc477e08 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController return if cached_blob? - if @blob.valid_lfs_pointer? + if @blob.stored_externally? send_lfs_object else send_git_blob @repository, @blob diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index 61686499bd3..6966a7c5fee 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -1,33 +1,11 @@ class Projects::UploadsController < Projects::ApplicationController + include UploadsActions + skip_before_action :project, :repository, if: -> { action_name == 'show' && image_or_video? } before_action :authorize_upload_file!, only: [:create] - def create - link_to_file = ::Projects::UploadService.new(project, params[:file]). - execute - - respond_to do |format| - if link_to_file - format.json do - render json: { link: link_to_file } - end - else - format.json do - render json: 'Invalid file.', status: :unprocessable_entity - end - end - end - end - - def show - return render_404 if uploader.nil? || !uploader.file.exists? - - disposition = uploader.image_or_video? ? 'inline' : 'attachment' - send_file uploader.file.path, disposition: disposition - end - private def uploader @@ -52,4 +30,10 @@ class Projects::UploadsController < Projects::ApplicationController def image_or_video? uploader && uploader.file.exists? && uploader.image_or_video? end + + def uploader_class + FileUploader + end + + alias_method :model, :project end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 96125684da0..887d18dbec3 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -1,6 +1,4 @@ class Projects::WikisController < Projects::ApplicationController - include MarkdownPreview - before_action :authorize_read_wiki! before_action :authorize_create_wiki!, only: [:edit, :create, :history] before_action :authorize_admin_wiki!, only: :destroy @@ -97,9 +95,14 @@ class Projects::WikisController < Projects::ApplicationController end def preview_markdown - context = { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } - - render_markdown_preview(params[:text], context) + result = PreviewMarkdownService.new(@project, current_user, params).execute + + render json: { + body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]), + references: { + users: result[:users] + } + } end private diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 9f6ee4826e6..69310b26e76 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,7 +1,6 @@ class ProjectsController < Projects::ApplicationController include IssuableCollections include ExtractsPath - include MarkdownPreview before_action :authenticate_user!, except: [:index, :show, :activity, :refs] before_action :project, except: [:index, :new, :create] @@ -240,7 +239,15 @@ class ProjectsController < Projects::ApplicationController end def preview_markdown - render_markdown_preview(params[:text]) + result = PreviewMarkdownService.new(@project, current_user, params).execute + + render json: { + body: view_context.markdown(result[:text]), + references: { + users: result[:users], + commands: view_context.markdown(result[:commands]) + } + } end private diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb index 3c4ddc1680d..f9496787b15 100644 --- a/app/controllers/snippets/notes_controller.rb +++ b/app/controllers/snippets/notes_controller.rb @@ -13,15 +13,6 @@ class Snippets::NotesController < ApplicationController end alias_method :awardable, :note - def note_html(note) - render_to_string( - "shared/notes/_note", - layout: false, - formats: [:html], - locals: { note: note } - ) - end - def project nil end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index da1ae9a34d9..19e07e3ab86 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -3,7 +3,6 @@ class SnippetsController < ApplicationController include ToggleAwardEmoji include SpammableActions include SnippetsActions - include MarkdownPreview include RendersBlob before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] @@ -65,6 +64,7 @@ class SnippetsController < ApplicationController blob = @snippet.blob override_max_blob_size(blob) + @note = Note.new(noteable: @snippet) @noteable = @snippet @discussions = @snippet.discussions @@ -90,7 +90,14 @@ class SnippetsController < ApplicationController end def preview_markdown - render_markdown_preview(params[:text], skip_project_check: true) + result = PreviewMarkdownService.new(@project, current_user, params).execute + + render json: { + body: view_context.markdown(result[:text], skip_project_check: true), + references: { + users: result[:users] + } + } end protected diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index f1bfd574f04..21a964fb391 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -1,50 +1,43 @@ class UploadsController < ApplicationController - skip_before_action :authenticate_user! - before_action :find_model, :authorize_access! - - def show - uploader = @model.send(upload_mount) - - unless uploader.file_storage? - return redirect_to uploader.url - end + include UploadsActions - unless uploader.file && uploader.file.exists? - return render_404 - end - - disposition = uploader.image? ? 'inline' : 'attachment' - - expires_in 0.seconds, must_revalidate: true, private: true - send_file uploader.file.path, disposition: disposition - end + skip_before_action :authenticate_user! + before_action :find_model + before_action :authorize_access!, only: [:show] + before_action :authorize_create_access!, only: [:create] private def find_model - unless upload_model && upload_mount - return render_404 - end + return render_404 unless upload_model && upload_mount @model = upload_model.find(params[:id]) end def authorize_access! authorized = - case @model - when Project - can?(current_user, :read_project, @model) - when Group - can?(current_user, :read_group, @model) + case model when Note - can?(current_user, :read_project, @model.project) - else - # No authentication required for user avatars. + can?(current_user, :read_project, model.project) + when User true + else + permission = "read_#{model.class.to_s.underscore}".to_sym + + can?(current_user, permission, model) end - return if authorized + render_unauthorized unless authorized + end + + def authorize_create_access! + # for now we support only personal snippets comments + authorized = can?(current_user, :comment_personal_snippet, model) + render_unauthorized unless authorized + end + + def render_unauthorized if current_user render_404 else @@ -58,17 +51,44 @@ class UploadsController < ApplicationController "project" => Project, "note" => Note, "group" => Group, - "appearance" => Appearance + "appearance" => Appearance, + "personal_snippet" => PersonalSnippet } upload_models[params[:model]] end def upload_mount + return true unless params[:mounted_as] + upload_mounts = %w(avatar attachment file logo header_logo) if upload_mounts.include?(params[:mounted_as]) params[:mounted_as] end end + + def uploader + return @uploader if defined?(@uploader) + + if model.is_a?(PersonalSnippet) + @uploader = PersonalFileUploader.new(model, params[:secret]) + + @uploader.retrieve_from_store!(params[:filename]) + else + @uploader = @model.send(upload_mount) + + redirect_to @uploader.url unless @uploader.file_storage? + end + + @uploader + end + + def uploader_class + PersonalFileUploader + end + + def model + @model ||= find_model + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a452bbba422..ca89ed221c6 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,7 +1,8 @@ class UsersController < ApplicationController + include RoutableActions + skip_before_action :authenticate_user! before_action :user, except: [:exists] - before_action :authorize_read_user!, only: [:show] def show respond_to do |format| @@ -91,12 +92,8 @@ class UsersController < ApplicationController private - def authorize_read_user! - render_404 unless can?(current_user, :read_user, user) - end - def user - @user ||= User.find_by_username!(params[:username]) + @user ||= find_routable!(User, params[:username]) end def contributed_projects diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 4cc42b88a2a..957ad875858 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -231,7 +231,7 @@ class IssuableFinder when 'created-by-me', 'authored' items.where(author_id: current_user.id) when 'assigned-to-me' - items.where(assignee_id: current_user.id) + items.assigned_to(current_user) else items end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 76715e5970d..b4c074bc69c 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder IssuesFinder.not_restricted_by_confidentiality(current_user) end + def by_assignee(items) + if assignee + items.assigned_to(assignee) + elsif no_assignee? + items.unassigned + elsif assignee_id? || assignee_username? # assignee not found + items.none + else + items + end + end + def self.not_restricted_by_confidentiality(user) - return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank? + return Issue.where('issues.confidential IS NOT TRUE') if user.blank? return Issue.all if user.admin? Issue.where(' - issues.confidential IS NULL - OR issues.confidential IS FALSE + issues.confidential IS NOT TRUE OR (issues.confidential = TRUE AND (issues.author_id = :user_id - OR issues.assignee_id = :user_id + OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id) OR issues.project_id IN(:project_ids)))', user_id: user.id, project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index a9172f6767f..f187a3b61fe 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -1,29 +1,23 @@ class PipelinesFinder - attr_reader :project, :pipelines + attr_reader :project, :pipelines, :params - def initialize(project) + ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze + + def initialize(project, params = {}) @project = project @pipelines = project.pipelines + @params = params end - def execute(scope: nil) - scoped_pipelines = - case scope - when 'running' - pipelines.running - when 'pending' - pipelines.pending - when 'finished' - pipelines.finished - when 'branches' - from_ids(ids_for_ref(branches)) - when 'tags' - from_ids(ids_for_ref(tags)) - else - pipelines - end - - scoped_pipelines.order(id: :desc) + def execute + items = pipelines + items = by_scope(items) + items = by_status(items) + items = by_ref(items) + items = by_name(items) + items = by_username(items) + items = by_yaml_errors(items) + sort_items(items) end private @@ -43,4 +37,78 @@ class PipelinesFinder def tags project.repository.tag_names end + + def by_scope(items) + case params[:scope] + when 'running' + items.running + when 'pending' + items.pending + when 'finished' + items.finished + when 'branches' + from_ids(ids_for_ref(branches)) + when 'tags' + from_ids(ids_for_ref(tags)) + else + items + end + end + + def by_status(items) + return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status]) + + items.where(status: params[:status]) + end + + def by_ref(items) + if params[:ref].present? + items.where(ref: params[:ref]) + else + items + end + end + + def by_name(items) + if params[:name].present? + items.joins(:user).where(users: { name: params[:name] }) + else + items + end + end + + def by_username(items) + if params[:username].present? + items.joins(:user).where(users: { username: params[:username] }) + else + items + end + end + + def by_yaml_errors(items) + case Gitlab::Utils.to_boolean(params[:yaml_errors]) + when true + items.where("yaml_errors IS NOT NULL") + when false + items.where("yaml_errors IS NULL") + else + items + end + end + + def sort_items(items) + order_by = if ALLOWED_INDEXED_COLUMNS.include?(params[:order_by]) + params[:order_by] + else + :id + end + + sort = if params[:sort] =~ /\A(ASC|DESC)\z/i + params[:sort] + else + :desc + end + + items.order(order_by => sort) + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fff57472a4f..6d6bcbaf88a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -180,16 +180,16 @@ module ApplicationHelper element end - def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false) - return if object.updated_at == object.created_at + def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) + return if object.last_edited_at == object.created_at || object.last_edited_at.blank? - content_tag :small, class: "edited-text" do - output = content_tag(:span, "Edited ") - output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) + content_tag :small, class: 'edited-text' do + output = content_tag(:span, 'Edited ') + output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class) - if include_author && object.updated_by && object.updated_by != object.author - output << content_tag(:span, " by ") - output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) + if !exclude_author && object.last_edited_by + output << content_tag(:span, ' by ') + output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil) end output diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 377b080b3c6..af430270ae4 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -52,7 +52,7 @@ module BlobHelper if !on_top_of_branch?(project, ref) button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' } - elsif blob.valid_lfs_pointer? + elsif blob.stored_externally? button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' } elsif can_modify_blob?(blob, project, ref) button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' @@ -95,7 +95,7 @@ module BlobHelper end def can_modify_blob?(blob, project = @project, ref = @ref) - !blob.valid_lfs_pointer? && can_edit_tree?(project, ref) + !blob.stored_externally? && can_edit_tree?(project, ref) end def leave_edit_message @@ -119,7 +119,9 @@ module BlobHelper end def blob_raw_url - if @snippet + if @build && @entry + raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) else @@ -223,7 +225,9 @@ module BlobHelper end def open_raw_blob_button(blob) - if blob.raw_binary? + return if blob.empty? + + if blob.raw_binary? || blob.stored_externally? icon = icon('download') title = 'Download' else @@ -244,19 +248,29 @@ module BlobHelper viewer.max_size end "it is larger than #{number_to_human_size(max_size)}" - when :server_side_but_stored_in_lfs - "it is stored in LFS" + when :server_side_but_stored_externally + case viewer.blob.external_storage + when :lfs + 'it is stored in LFS' + when :build_artifact + 'it is stored as a job artifact' + else + 'it is stored externally' + end end end def blob_render_error_options(viewer) + error = viewer.render_error options = [] - if viewer.render_error == :too_large && viewer.can_override_max_size? + if error == :too_large && viewer.can_override_max_size? options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil))) end - if viewer.rich? && viewer.blob.rendered_as_text? + # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error, + # so don't bother switching. + if viewer.rich? && viewer.blob.rendered_as_text? && error != :server_side_but_stored_externally options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' }) end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index f43827da446..e2df52e3833 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -9,6 +9,7 @@ module BoardsHelper issue_link_base: namespace_project_issues_path(@project.namespace, @project), root_path: root_path, bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project), + default_avatar: image_path(default_avatar) } end end diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 2fcb7a59fc3..2eb2c6c7389 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -1,4 +1,16 @@ module BuildsHelper + def build_summary(build, skip: false) + if build.has_trace? + if skip + link_to "View job trace", pipeline_build_url(build.pipeline, build) + else + build.trace.html(last_lines: 10).html_safe + end + else + "No job trace" + end + end + def sidebar_build_class(build, current_build) build_class = '' build_class += ' active' if build.id === current_build.id diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 1182939f656..53962b84618 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -15,4 +15,36 @@ module FormHelper end end end + + def issue_dropdown_options(issuable, has_multiple_assignees = true) + options = { + toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data', + title: 'Select assignee', + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', + placeholder: 'Search users', + data: { + first_user: current_user&.username, + null_user: true, + current_user: true, + project_id: issuable.project.try(:id), + field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]", + default_label: 'Assignee', + 'max-select': 1, + 'dropdown-header': 'Assignee', + multi_select: true, + 'input-meta': 'name', + 'always-show-selectbox': true, + current_user_info: current_user.to_json(only: [:id, :name]) + } + } + + if has_multiple_assignees + options[:title] = 'Select assignee(s)' + options[:data][:'dropdown-header'] = 'Assignee(s)' + options[:data].delete(:'max-select') + end + + options + end end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index e9b7cbbad6a..3769830de2a 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -122,6 +122,14 @@ module GitlabRoutingHelper namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args) end + def preview_markdown_path(project, *args) + if @snippet.is_a?(PersonalSnippet) + preview_markdown_snippet_path(@snippet) + else + preview_markdown_namespace_project_path(project.namespace, project, *args) + end + end + def toggle_subscription_path(entity, *args) if entity.is_a?(Issue) toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity) @@ -208,6 +216,8 @@ module GitlabRoutingHelper browse_namespace_project_build_artifacts_path(*args) when 'file' file_namespace_project_build_artifacts_path(*args) + when 'raw' + raw_namespace_project_build_artifacts_path(*args) end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 0b13dbf5f8d..7656929efe7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -63,6 +63,16 @@ module IssuablesHelper end end + def users_dropdown_label(selected_users) + if selected_users.length == 0 + "Unassigned" + elsif selected_users.length == 1 + selected_users[0].name + else + "#{selected_users[0].name} + #{selected_users.length - 1} more" + end + end + def user_dropdown_label(user_id, default_label) return default_label if user_id.nil? return "Unassigned" if user_id == "0" diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index c9e70faa52e..c515774140c 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -115,4 +115,28 @@ module MilestonesHelper end end end + + def milestone_merge_request_tab_path(milestone) + if @project + merge_requests_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end + + def milestone_participants_tab_path(milestone) + if @project + participants_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end + + def milestone_labels_tab_path(milestone) + if @project + labels_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index eab0738a368..52403640c05 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -60,24 +60,63 @@ module NotesHelper note.project.team.human_max_access(note.author_id) end - def discussion_diff_path(discussion) - if discussion.for_merge_request? && discussion.diff_discussion? - if discussion.active? - # Without a diff ID, the link always points to the latest diff version - diff_id = nil - elsif merge_request_diff = discussion.latest_merge_request_diff - diff_id = merge_request_diff.id - else - # If the discussion is not active, and we cannot find the latest - # merge request diff for this discussion, we return no path at all. - return - end - - diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, diff_id: diff_id, anchor: discussion.line_code) + def discussion_path(discussion) + if discussion.for_merge_request? + return unless discussion.diff_discussion? + + version_params = discussion.merge_request_version_params + return unless version_params + + path_params = version_params.merge(anchor: discussion.line_code) + + diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, path_params) elsif discussion.for_commit? anchor = discussion.line_code if discussion.diff_discussion? namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor) end end + + def notes_url + if @snippet.is_a?(PersonalSnippet) + snippet_notes_path(@snippet) + else + namespace_project_noteable_notes_path( + namespace_id: @project.namespace, + project_id: @project, + target_id: @noteable.id, + target_type: @noteable.class.name.underscore + ) + end + end + + def note_url(note) + if note.noteable.is_a?(PersonalSnippet) + snippet_note_path(note.noteable, note) + else + namespace_project_note_path(@project.namespace, @project, note) + end + end + + def form_resources + if @snippet.is_a?(PersonalSnippet) + [@note] + else + [@project.namespace.becomes(Namespace), @project, @note] + end + end + + def new_form_url + return nil unless @snippet.is_a?(PersonalSnippet) + + snippet_notes_path(@snippet) + end + + def can_create_note? + if @snippet.is_a?(PersonalSnippet) + can?(current_user, :comment_personal_snippet, @snippet) + else + can?(current_user, :create_note, @project) + end + end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 2fda98cae90..4882d9b71d2 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -70,6 +70,14 @@ module SortingHelper } end + def tags_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + def sort_title_priority 'Priority' end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 1ea60e39386..d889d141101 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -1,6 +1,7 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { 'commit' => 'icon_commit', + 'description' => 'icon_edit', 'merge' => 'icon_merge', 'merged' => 'icon_merged', 'opened' => 'icon_status_open', diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index f7b5a5f4dfc..a91e3da309c 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -76,7 +76,7 @@ module TreeHelper "A new branch will be created in your fork and a new merge request will be started." end - def tree_breadcrumbs(tree, max_links = 2) + def path_breadcrumbs(max_links = 6) if @path.present? part_path = "" parts = @path.split('/') @@ -88,7 +88,7 @@ module TreeHelper part_path = part if part_path.empty? next if parts.count > max_links && !parts.last(2).include?(part) - yield(part, tree_join(@ref, part_path)) + yield(part, part_path) end end end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index d64e48f774b..0f847841295 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -11,10 +11,12 @@ module Emails mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end - def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) + def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) - @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id + @previous_assignees = [] + @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index cf042717c95..54f01f8637e 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -62,6 +62,10 @@ class ApplicationSetting < ActiveRecord::Base presence: true, if: :sentry_enabled + validates :clientside_sentry_dsn, + presence: true, + if: :clientside_sentry_enabled + validates :akismet_api_key, presence: true, if: :akismet_enabled diff --git a/app/models/blob.rb b/app/models/blob.rb index 1cdb8811cff..eaf0b713122 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -26,9 +26,10 @@ class Blob < SimpleDelegator BlobViewer::Image, BlobViewer::Sketch, + BlobViewer::Balsamiq, BlobViewer::Video, - + BlobViewer::PDF, BlobViewer::BinarySTL, @@ -75,19 +76,37 @@ class Blob < SimpleDelegator end def no_highlighting? - size && size > MAXIMUM_TEXT_HIGHLIGHT_SIZE + raw_size && raw_size > MAXIMUM_TEXT_HIGHLIGHT_SIZE + end + + def empty? + raw_size == 0 end def too_large? size && truncated? end + def external_storage_error? + if external_storage == :lfs + !project&.lfs_enabled? + else + false + end + end + + def stored_externally? + return @stored_externally if defined?(@stored_externally) + + @stored_externally = external_storage && !external_storage_error? + end + # Returns the size of the file that this blob represents. If this blob is an # LFS pointer, this is the size of the file stored in LFS. Otherwise, this is # the size of the blob itself. def raw_size - if valid_lfs_pointer? - lfs_size + if stored_externally? + external_size else size end @@ -98,9 +117,13 @@ class Blob < SimpleDelegator # text-based rich blob viewer matched on the file's extension. Otherwise, this # depends on the type of the blob itself. def raw_binary? - if valid_lfs_pointer? + if stored_externally? if rich_viewer rich_viewer.binary? + elsif Linguist::Language.find_by_filename(name).any? + false + elsif _mime_type + _mime_type.binary? else true end @@ -118,15 +141,7 @@ class Blob < SimpleDelegator end def readable_text? - text? && !valid_lfs_pointer? && !too_large? - end - - def valid_lfs_pointer? - lfs_pointer? && project&.lfs_enabled? - end - - def invalid_lfs_pointer? - lfs_pointer? && !project&.lfs_enabled? + text? && !stored_externally? && !too_large? end def simple_viewer @@ -165,10 +180,10 @@ class Blob < SimpleDelegator end def rich_viewer_class - return if invalid_lfs_pointer? || empty? + return if empty? || external_storage_error? classes = - if valid_lfs_pointer? + if stored_externally? BINARY_VIEWERS + TEXT_VIEWERS elsif binary? BINARY_VIEWERS diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb new file mode 100644 index 00000000000..f982521db99 --- /dev/null +++ b/app/models/blob_viewer/balsamiq.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Balsamiq < Base + include Rich + include ClientSide + + self.partial_name = 'balsamiq' + self.extensions = %w(bmpr) + self.binary = true + self.switcher_icon = 'file-image-o' + self.switcher_title = 'preview' + end +end diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index f944b00c9d3..a8b91d8d6bc 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -70,12 +70,13 @@ module BlobViewer return @render_error if defined?(@render_error) @render_error = - if server_side_but_stored_in_lfs? - # Files stored in LFS can only be rendered using a client-side viewer, + if server_side_but_stored_externally? + # Files that are not stored in the repository, like LFS files and + # build artifacts, can only be rendered using a client-side viewer, # since we do not want to read large amounts of data into memory on the # server side. Client-side viewers use JS and can fetch the file from # `blob_raw_url` using AJAX. - :server_side_but_stored_in_lfs + :server_side_but_stored_externally elsif override_max_size ? absolutely_too_large? : too_large? :too_large end @@ -89,8 +90,8 @@ module BlobViewer private - def server_side_but_stored_in_lfs? - server_side? && blob.valid_lfs_pointer? + def server_side_but_stored_externally? + server_side? && blob.stored_externally? end end end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb new file mode 100644 index 00000000000..b35febc9ac5 --- /dev/null +++ b/app/models/ci/artifact_blob.rb @@ -0,0 +1,35 @@ +module Ci + class ArtifactBlob + include BlobLike + + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + delegate :name, :path, to: :entry + + def id + Digest::SHA1.hexdigest(path) + end + + def size + entry.metadata[:size] + end + + def data + "Build artifact #{path}" + end + + def mode + entry.metadata[:mode] + end + + def external_storage + :build_artifact + end + + alias_method :external_size, :size + end +end diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb new file mode 100644 index 00000000000..87898b086c6 --- /dev/null +++ b/app/models/ci/group.rb @@ -0,0 +1,40 @@ +module Ci + ## + # This domain model is a representation of a group of jobs that are related + # to each other, like `rspec 0 1`, `rspec 0 2`. + # + # It is not persisted in the database. + # + class Group + include StaticModel + + attr_reader :stage, :name, :jobs + + delegate :size, to: :jobs + + def initialize(stage, name:, jobs:) + @stage = stage + @name = name + @jobs = jobs + end + + def status + @status ||= commit_statuses.status + end + + def detailed_status(current_user) + if jobs.one? + jobs.first.detailed_status(current_user) + else + Gitlab::Ci::Status::Group::Factory + .new(self, current_user).fabricate! + end + end + + private + + def commit_statuses + @commit_statuses ||= CommitStatus.where(id: jobs.map(&:id)) + end + end +end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index e7d6b17d445..9bda3186c30 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -15,6 +15,14 @@ module Ci @warnings = warnings end + def groups + @groups ||= statuses.ordered.latest + .sort_by(&:sortable_name).group_by(&:group_name) + .map do |group_name, grouped_statuses| + Ci::Group.new(self, name: group_name, jobs: grouped_statuses) + end + end + def to_param name end diff --git a/app/models/commit.rb b/app/models/commit.rb index bb4cb8efd15..88a015cdb77 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -236,8 +236,8 @@ class Commit project.pipelines.where(sha: sha) end - def latest_pipeline - pipelines.last + def last_pipeline + @last_pipeline ||= pipelines.last end def status(ref = nil) diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb new file mode 100644 index 00000000000..adb81561000 --- /dev/null +++ b/app/models/concerns/blob_like.rb @@ -0,0 +1,48 @@ +module BlobLike + extend ActiveSupport::Concern + include Linguist::BlobHelper + + def id + raise NotImplementedError + end + + def name + raise NotImplementedError + end + + def path + raise NotImplementedError + end + + def size + 0 + end + + def data + nil + end + + def mode + nil + end + + def binary? + false + end + + def load_all_data!(repository) + # No-op + end + + def truncated? + false + end + + def external_storage + nil + end + + def external_size + nil + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index f033028c4e5..eb32bf3d32a 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -78,6 +78,9 @@ module CacheMarkdownField def cached_html_up_to_date?(markdown_field) html_field = cached_markdown_fields.html_field(markdown_field) + cached = !cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil? + return false unless cached + markdown_changed = attribute_changed?(markdown_field) || false html_changed = attribute_changed?(html_field) || false diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index 8ee42875670..a7bdf5587b2 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -11,6 +11,7 @@ module DiscussionOnDiff :diff_line, :for_line?, :active?, + :created_at_diff?, to: :first_note diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 26dbf4d9570..075ec575f9d 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -26,8 +26,8 @@ module Issuable cache_markdown_field :description, issuable_state_filter_enabled: true belongs_to :author, class_name: "User" - belongs_to :assignee, class_name: "User" belongs_to :updated_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do def authors_loaded? @@ -65,11 +65,8 @@ module Issuable validates :title, presence: true, length: { maximum: 255 } scope :authored, ->(user) { where(author_id: user) } - scope :assigned_to, ->(u) { where(assignee_id: u.id)} scope :recent, -> { reorder(id: :desc) } scope :order_position_asc, -> { reorder(position: :asc) } - scope :assigned, -> { where("assignee_id IS NOT NULL") } - scope :unassigned, -> { where("assignee_id IS NULL") } scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } @@ -92,23 +89,14 @@ module Issuable attr_mentionable :description participant :author - participant :assignee participant :notes_with_associations strip_attributes :title acts_as_paranoid - after_save :update_assignee_cache_counts, if: :assignee_id_changed? after_save :record_metrics, unless: :imported? - def update_assignee_cache_counts - # make sure we flush the cache for both the old *and* new assignees(if they exist) - previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was - previous_assignee&.update_cache_counts - assignee&.update_cache_counts - end - # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? @@ -237,10 +225,6 @@ module Issuable today? && created_at == updated_at end - def is_being_reassigned? - assignee_id_changed? - end - def open? opened? || reopened? end @@ -269,7 +253,11 @@ module Issuable # DEPRECATED repository: project.hook_attrs.slice(:name, :url, :description, :homepage) } - hook_data[:assignee] = assignee.hook_attrs if assignee + if self.is_a?(Issue) + hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any? + else + hook_data[:assignee] = assignee.hook_attrs if assignee + end hook_data end @@ -331,11 +319,6 @@ module Issuable false end - def assignee_or_author?(user) - # We're comparing IDs here so we don't need to load any associations. - author_id == user.id || assignee_id == user.id - end - def record_metrics metrics = self.metrics || create_metrics metrics.record! diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index f449229864d..a3472af5c55 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -40,7 +40,7 @@ module Milestoneish def issues_visible_to_user(user) memoize_per_user(user, :issues_visible_to_user) do IssuesFinder.new(user, issues_finder_params) - .execute.where(milestone_id: milestoneish_ids) + .execute.includes(:assignees).where(milestone_id: milestoneish_ids) end end diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index 6c27dd5aa5c..6359f7596b1 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -30,6 +30,10 @@ module NoteOnDiff raise NotImplementedError end + def created_at_diff?(diff_refs) + false + end + private def noteable_diff_refs diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index b28e05d0c28..c4463abdfe6 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -5,6 +5,7 @@ module Routable included do has_one :route, as: :source, autosave: true, dependent: :destroy + has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy validates_associated :route validates :route, presence: true @@ -26,16 +27,31 @@ module Routable # Klass.find_by_full_path('gitlab-org/gitlab-ce') # # Returns a single object, or nil. - def find_by_full_path(path) + def find_by_full_path(path, follow_redirects: false) # On MySQL we want to ensure the ORDER BY uses a case-sensitive match so # any literal matches come first, for this we have to use "BINARY". # Without this there's still no guarantee in what order MySQL will return # rows. + # + # Why do we do this? + # + # Even though we have Rails validation on Route for unique paths + # (case-insensitive), there are old projects in our DB (and possibly + # clients' DBs) that have the same path with different cases. + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/18603. Also note that + # our unique index is case-sensitive in Postgres. binary = Gitlab::Database.mysql? ? 'BINARY' : '' - order_sql = "(CASE WHEN #{binary} routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)" - - where_full_path_in([path]).reorder(order_sql).take + found = where_full_path_in([path]).reorder(order_sql).take + return found if found + + if follow_redirects + if Gitlab::Database.postgresql? + joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path) + else + joins(:redirect_routes).find_by(redirect_routes: { path: path }) + end + end end # Builds a relation to find multiple objects by their full paths. diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 6a6466b493b..d627fbe327f 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -10,7 +10,6 @@ class DiffDiscussion < Discussion delegate :position, :original_position, - :latest_merge_request_diff, to: :first_note @@ -18,6 +17,25 @@ class DiffDiscussion < Discussion false end + def merge_request_version_params + return unless for_merge_request? + + if active? + {} + else + diff_refs = position.diff_refs + + if diff = noteable.merge_request_diff_for(diff_refs) + { diff_id: diff.id } + elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha) + { + diff_id: diff.id, + start_sha: diff_refs.start_sha + } + end + end + end + def reply_attributes super.merge( original_position: original_position.to_json, diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index abe4518d62a..76c59199afd 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -65,10 +65,11 @@ class DiffNote < Note self.position.diff_refs == diff_refs end - def latest_merge_request_diff - return unless for_merge_request? + def created_at_diff?(diff_refs) + return false unless supported? + return true if for_commit? - self.noteable.merge_request_diff_for(self.position.diff_refs) + self.original_position.diff_refs == diff_refs end private diff --git a/app/models/event.rb b/app/models/event.rb index b780c1faf81..e6fad46077a 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -30,6 +30,7 @@ class Event < ActiveRecord::Base # Callbacks after_create :reset_project_activity + after_create :set_last_repository_updated_at, if: :push? # Scopes scope :recent, -> { reorder(id: :desc) } @@ -357,4 +358,9 @@ class Event < ActiveRecord::Base def recent_update? project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago end + + def set_last_repository_updated_at + Project.unscoped.where(id: project_id). + update_all(last_repository_updated_at: created_at) + end end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 0afbca2cb32..538615130a7 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -36,7 +36,7 @@ class GlobalMilestone closed = count_by_state(milestones_by_state_and_title, 'closed') all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count - { + { opened: opened, closed: closed, all: all @@ -86,7 +86,7 @@ class GlobalMilestone end def issues - @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels) + @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels) end def merge_requests @@ -94,7 +94,7 @@ class GlobalMilestone end def participants - @participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq + @participants ||= milestones.map(&:participants).flatten.uniq end def labels diff --git a/app/models/issue.rb b/app/models/issue.rb index 305fc01f041..27e3ed9bc7f 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + has_many :issue_assignees + has_many :assignees, class_name: "User", through: :issue_assignees + validates :project, presence: true scope :in_projects, ->(project_ids) { where(project_id: project_ids) } + scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)} + scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } @@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } - scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) } + scope :include_associations, -> { includes(:labels, project: :namespace) } after_save :expire_etag_cache attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true + participant :assignees + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed @@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base end def hook_attrs + assignee_ids = self.assignee_ids + attrs = { total_time_spent: total_time_spent, human_total_time_spent: human_total_time_spent, - human_time_estimate: human_time_estimate + human_time_estimate: human_time_estimate, + assignee_ids: assignee_ids, + assignee_id: assignee_ids.first # This key is deprecated } attributes.merge!(attrs) @@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base "id DESC") end + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee_list + } + end + + def assignee_or_author?(user) + author_id == user.id || assignees.exists?(user.id) + end + + def assignee_list + assignees.map(&:name).to_sentence + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -143,6 +172,14 @@ class Issue < ActiveRecord::Base branches_with_iid - branches_with_merge_request end + # Returns boolean if a related branch exists for the current issue + # ignores merge requests branchs + def has_related_branch? + project.repository.branch_names.any? do |branch| + /\A#{iid}-(?!\d+-stable)/i =~ branch + end + end + # To allow polymorphism with MergeRequest. def source_project project @@ -240,7 +277,7 @@ class Issue < ActiveRecord::Base true elsif confidential? author == user || - assignee == user || + assignees.include?(user) || project.team.member?(user, Gitlab::Access::REPORTER) else project.public? || diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb new file mode 100644 index 00000000000..0663d3aaef8 --- /dev/null +++ b/app/models/issue_assignee.rb @@ -0,0 +1,13 @@ +class IssueAssignee < ActiveRecord::Base + extend Gitlab::CurrentSettings + + belongs_to :issue + belongs_to :assignee, class_name: "User", foreign_key: :user_id + + after_create :update_assignee_cache_counts + after_destroy :update_assignee_cache_counts + + def update_assignee_cache_counts + assignee&.update_cache_counts + end +end diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb index e617ce36f56..3c1d34db5fa 100644 --- a/app/models/legacy_diff_discussion.rb +++ b/app/models/legacy_diff_discussion.rb @@ -9,14 +9,14 @@ class LegacyDiffDiscussion < Discussion memoized_values << :active - def legacy_diff_discussion? - true - end - def self.note_class LegacyDiffNote end + def legacy_diff_discussion? + true + end + def active?(*args) return @active if @active.present? @@ -27,6 +27,16 @@ class LegacyDiffDiscussion < Discussion !active? end + def merge_request_version_params + return unless for_merge_request? + + if active? + {} + else + nil + end + end + def reply_attributes super.merge(line_code: line_code) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 365fa4f1e70..35231bab12e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + belongs_to :assignee, class_name: "User" + serialize :merge_params, Hash after_create :ensure_merge_request_diff, unless: :importing? @@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } + scope :assigned, -> { where("assignee_id IS NOT NULL") } + scope :unassigned, -> { where("assignee_id IS NULL") } + scope :assigned_to, ->(u) { where(assignee_id: u.id)} + + participant :assignee after_save :keep_around_commit + after_save :update_assignee_cache_counts, if: :assignee_id_changed? def self.reference_prefix '!' @@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base work_in_progress?(title) ? title : "WIP: #{title}" end + def update_assignee_cache_counts + # make sure we flush the cache for both the old *and* new assignees(if they exist) + previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was + previous_assignee&.update_cache_counts + assignee&.update_cache_counts + end + + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee.try(:name) + } + end + + # This method is needed for compatibility with issues to not mess view and other code + def assignees + Array(assignee) + end + + def assignee_or_author?(user) + author_id == user.id || assignee_id == user.id + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -374,12 +406,18 @@ class MergeRequest < ActiveRecord::Base merge_request_diff(true) end - def merge_request_diff_for(diff_refs) - @merge_request_diffs_by_diff_refs ||= Hash.new do |h, diff_refs| - h[diff_refs] = merge_request_diffs.viewable.select_without_diff.find_by_diff_refs(diff_refs) + def merge_request_diff_for(diff_refs_or_sha) + @merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha| + diffs = merge_request_diffs.viewable.select_without_diff + h[diff_refs_or_sha] = + if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs) + diffs.find_by_diff_refs(diff_refs_or_sha) + else + diffs.find_by(head_commit_sha: diff_refs_or_sha) + end end - @merge_request_diffs_by_diff_refs[diff_refs] + @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha] end def reload_diff_if_branch_changed diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 652b1551928..c06bfe0ccdd 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests - has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee has_many :events, as: :target, dependent: :destroy scope :active, -> { with_state(:active) } @@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base end end + def participants + User.joins(assigned_issues: :milestone).where("milestones.id = ?", id) + end + def self.sort(method) case method.to_s when 'due_date_asc' diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 9bfa731785f..397dc7a25ab 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -33,7 +33,7 @@ class Namespace < ActiveRecord::Base validates :path, presence: true, length: { maximum: 255 }, - namespace: true + dynamic_path: true validate :nesting_level_allowed @@ -220,6 +220,10 @@ class Namespace < ActiveRecord::Base Project.inside_path(full_path) end + def has_parent? + parent.present? + end + private def repository_storage_paths diff --git a/app/models/note.rb b/app/models/note.rb index e720bfba030..46d0a4f159f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -18,6 +18,11 @@ class Note < ActiveRecord::Base cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. attr_accessor :redacted_note_html @@ -38,6 +43,7 @@ class Note < ActiveRecord::Base belongs_to :noteable, polymorphic: true, touch: true belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy @@ -115,11 +121,19 @@ class Note < ActiveRecord::Base end def grouped_diff_discussions(diff_refs = nil) - diff_notes. - fresh. - discussions. - select { |n| n.active?(diff_refs) }. - group_by(&:line_code) + groups = {} + + diff_notes.fresh.discussions.each do |discussion| + if discussion.active?(diff_refs) + discussions = groups[discussion.line_code] ||= [] + elsif diff_refs && discussion.created_at_diff?(diff_refs) + discussions = groups[discussion.original_line_code] ||= [] + end + + discussions << discussion if discussions + end + + groups end def count_for_collection(ids, type) @@ -141,10 +155,6 @@ class Note < ActiveRecord::Base true end - def latest_merge_request_diff - nil - end - def max_attachment_size current_application_settings.max_attachment_size.megabytes.to_i end diff --git a/app/models/project.rb b/app/models/project.rb index 9d64e5d406d..edbca3b537b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -53,6 +53,11 @@ class Project < ActiveRecord::Base update_column(:last_activity_at, self.created_at) end + after_create :set_last_repository_updated_at + def set_last_repository_updated_at + update_column(:last_repository_updated_at, self.created_at) + end + after_destroy :remove_pages # update visibility_level of forks @@ -196,13 +201,14 @@ class Project < ActiveRecord::Base message: Gitlab::Regex.project_name_regex_message } validates :path, presence: true, - project_path: true, + dynamic_path: true, length: { maximum: 255 }, format: { with: Gitlab::Regex.project_path_regex, - message: Gitlab::Regex.project_path_regex_message } + message: Gitlab::Regex.project_path_regex_message }, + uniqueness: { scope: :namespace_id } + validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } - validates :path, uniqueness: { scope: :namespace_id } validates :import_url, addressable_url: true, if: :external_import? validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } @@ -1270,6 +1276,9 @@ class Project < ActiveRecord::Base else update_attribute(name, value) end + + rescue ActiveRecord::RecordNotSaved => e + handle_update_attribute_error(e, value) end def pushes_since_gc @@ -1391,4 +1400,16 @@ class Project < ActiveRecord::Base ContainerRepository.build_root_repository(self).has_tags? end + + def handle_update_attribute_error(ex, value) + if ex.message.start_with?('Failed to replace') + if value.respond_to?(:each) + invalid = value.detect(&:invalid?) + + raise ex, ([ex.message] + invalid.errors.full_messages).join(' ') if invalid + end + end + + raise ex + end end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 7621a5fa2d8..e2ad586aea7 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -50,5 +50,16 @@ module ChatMessage def link(text, url) "[#{text}](#{url})" end + + def pretty_duration(seconds) + parse_string = + if duration < 1.hour + '%M:%S' + else + '%H:%M:%S' + end + + Time.at(seconds).utc.strftime(parse_string) + end end end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 4628d9b1a7b..47b68f00cff 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -15,7 +15,7 @@ module ChatMessage @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref = pipeline_attributes[:ref] @status = pipeline_attributes[:status] - @duration = pipeline_attributes[:duration] + @duration = pipeline_attributes[:duration].to_i @pipeline_id = pipeline_attributes[:id] end @@ -37,7 +37,7 @@ module ChatMessage { title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}", subtitle: "in #{project_link}", - text: "in #{duration} #{time_measure}", + text: "in #{pretty_duration(duration)}", image: user_avatar || '' } end @@ -45,7 +45,7 @@ module ChatMessage private def message - "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}" + "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}" end def humanized_status @@ -84,9 +84,5 @@ module ChatMessage def pipeline_link "[##{pipeline_id}](#{pipeline_url})" end - - def time_measure - 'second'.pluralize(duration) - end end end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 70eef359cdd..189c106b70b 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -183,6 +183,6 @@ class ProjectWiki end def update_project_activity - @project.touch(:last_activity_at) + @project.touch(:last_activity_at, :last_repository_updated_at) end end diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb new file mode 100644 index 00000000000..99812bcde53 --- /dev/null +++ b/app/models/redirect_route.rb @@ -0,0 +1,12 @@ +class RedirectRoute < ActiveRecord::Base + belongs_to :source, polymorphic: true + + validates :source, presence: true + + validates :path, + length: { within: 1..255 }, + presence: true, + uniqueness: { case_sensitive: false } + + scope :matching_path_and_descendants, -> (path) { where('redirect_routes.path = ? OR redirect_routes.path LIKE ?', path, "#{sanitize_sql_like(path)}/%") } +end diff --git a/app/models/repository.rb b/app/models/repository.rb index ba34d570dbd..0c797dd5814 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -789,7 +789,7 @@ class Repository } options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) - Rugged::Commit.create(rugged, options) + create_commit(options) end end # rubocop:enable Metrics/ParameterLists @@ -836,7 +836,7 @@ class Repository tree: merge_index.write_tree(rugged), ) - commit_id = Rugged::Commit.create(rugged, actual_options) + commit_id = create_commit(actual_options) merge_request.update(in_progress_merge_commit_sha: commit_id) commit_id end @@ -859,12 +859,11 @@ class Repository committer = user_to_committer(user) - Rugged::Commit.create(rugged, - message: commit.revert_message(user), - author: committer, - committer: committer, - tree: revert_tree_id, - parents: [start_commit.sha]) + create_commit(message: commit.revert_message(user), + author: committer, + committer: committer, + tree: revert_tree_id, + parents: [start_commit.sha]) end end @@ -883,16 +882,15 @@ class Repository committer = user_to_committer(user) - Rugged::Commit.create(rugged, - message: commit.message, - author: { - email: commit.author_email, - name: commit.author_name, - time: commit.authored_date - }, - committer: committer, - tree: cherry_pick_tree_id, - parents: [start_commit.sha]) + create_commit(message: commit.message, + author: { + email: commit.author_email, + name: commit.author_name, + time: commit.authored_date + }, + committer: committer, + tree: cherry_pick_tree_id, + parents: [start_commit.sha]) end end @@ -900,7 +898,7 @@ class Repository GitOperationService.new(user, self).with_branch(branch_name) do committer = user_to_committer(user) - Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer)) + create_commit(params.merge(author: committer, committer: committer)) end end @@ -1142,6 +1140,12 @@ class Repository Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags)) end + def create_commit(params = {}) + params[:message].delete!("\r") + + Rugged::Commit.create(rugged, params) + end + def repository_storage_path @project.repository_storage_path end diff --git a/app/models/route.rb b/app/models/route.rb index 4b3efab5c3c..12a7fa3d01b 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -8,29 +8,58 @@ class Route < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } + after_create :delete_conflicting_redirects + after_update :delete_conflicting_redirects, if: :path_changed? + after_update :create_redirect_for_old_path after_update :rename_descendants scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") } def rename_descendants - if path_changed? || name_changed? - descendants = self.class.inside_path(path_was) + return unless path_changed? || name_changed? - descendants.each do |route| - attributes = {} + descendant_routes = self.class.inside_path(path_was) - if path_changed? && route.path.present? - attributes[:path] = route.path.sub(path_was, path) - end + descendant_routes.each do |route| + attributes = {} - if name_changed? && name_was.present? && route.name.present? - attributes[:name] = route.name.sub(name_was, name) - end + if path_changed? && route.path.present? + attributes[:path] = route.path.sub(path_was, path) + end - # Note that update_columns skips validation and callbacks. - # We need this to avoid recursive call of rename_descendants method - route.update_columns(attributes) unless attributes.empty? + if name_changed? && name_was.present? && route.name.present? + attributes[:name] = route.name.sub(name_was, name) + end + + if attributes.present? + old_path = route.path + + # Callbacks must be run manually + route.update_columns(attributes) + + # We are not calling route.delete_conflicting_redirects here, in hopes + # of avoiding deadlocks. The parent (self, in this method) already + # called it, which deletes conflicts for all descendants. + route.create_redirect(old_path) if attributes[:path] end end end + + def delete_conflicting_redirects + conflicting_redirects.delete_all + end + + def conflicting_redirects + RedirectRoute.matching_path_and_descendants(path) + end + + def create_redirect(path) + RedirectRoute.create(source: source, path: path) + end + + private + + def create_redirect_for_old_path + create_redirect(path_was) if path_changed? + end end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index d8860718cb5..abfbefdf9a0 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -12,6 +12,11 @@ class Snippet < ActiveRecord::Base cache_markdown_field :title, pipeline: :single_line cache_markdown_field :content + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # If file_name changes, it invalidates content alias_method :default_content_html_invalidator, :content_html_invalidated? def content_html_invalidated? diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb index d6cab74eb1a..fa5fa151607 100644 --- a/app/models/snippet_blob.rb +++ b/app/models/snippet_blob.rb @@ -1,5 +1,5 @@ class SnippetBlob - include Linguist::BlobHelper + include BlobLike attr_reader :snippet @@ -28,32 +28,4 @@ class SnippetBlob Banzai.render_field(snippet, :content) end - - def mode - nil - end - - def binary? - false - end - - def load_all_data!(repository) - # No-op - end - - def lfs_pointer? - false - end - - def lfs_oid - nil - end - - def lfs_size - nil - end - - def truncated? - false - end end diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 1e6fc837a75..b44f4fe000c 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -1,6 +1,6 @@ class SystemNoteMetadata < ActiveRecord::Base ICON_TYPES = %w[ - commit merge confidential visible label assignee cross_reference + commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved opened closed merged ].freeze diff --git a/app/models/user.rb b/app/models/user.rb index bd9c9f99663..accaa91b805 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,6 +23,7 @@ class User < ActiveRecord::Base default_value_for :hide_no_password, false default_value_for :project_view, :files default_value_for :notified_of_own_activity, false + default_value_for :preferred_language, I18n.default_locale attr_encrypted :otp_secret, key: Gitlab::Application.secrets.otp_key_base, @@ -99,6 +100,10 @@ class User < ActiveRecord::Base has_many :award_emoji, dependent: :destroy has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id + has_many :issue_assignees + has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue + has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" + # Issues that a user owns are expected to be moved to the "ghost" user before # the user is destroyed. If the user owns any issues during deletion, this # should be treated as an exceptional condition. @@ -118,7 +123,7 @@ class User < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } validates :username, - namespace: true, + dynamic_path: true, presence: true, uniqueness: { case_sensitive: false } @@ -332,6 +337,11 @@ class User < ActiveRecord::Base find_by(id: Key.unscoped.select(:user_id).where(id: key_id)) end + def find_by_full_path(path, follow_redirects: false) + namespace = Namespace.find_by_full_path(path, follow_redirects: follow_redirects) + namespace&.owner + end + def reference_prefix '@' end @@ -354,6 +364,10 @@ class User < ActiveRecord::Base end end + def full_path + username + end + def self.internal_attributes [:ghost] end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index d3913986cd8..e1e5336da8c 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -3,11 +3,16 @@ class PersonalSnippetPolicy < BasePolicy can! :read_personal_snippet if @subject.public? return unless @user + if @subject.public? + can! :comment_personal_snippet + end + if @subject.author == @user can! :read_personal_snippet can! :update_personal_snippet can! :destroy_personal_snippet can! :admin_personal_snippet + can! :comment_personal_snippet end unless @user.external? @@ -16,6 +21,7 @@ class PersonalSnippetPolicy < BasePolicy if @subject.internal? && !@user.external? can! :read_personal_snippet + can! :comment_personal_snippet end end end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 86ac513b3c0..070b0c35e36 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -48,6 +48,17 @@ module Projects available_public_keys.any? end + def as_json + serializer = DeployKeySerializer.new + opts = { user: current_user } + + { + enabled_keys: serializer.represent(enabled_keys, opts), + available_project_keys: serializer.represent(available_project_keys, opts), + public_keys: serializer.represent(available_public_keys, opts) + } + end + def to_partial_path 'projects/deploy_keys/index' end diff --git a/app/serializers/README.md b/app/serializers/README.md new file mode 100644 index 00000000000..0337f88db5f --- /dev/null +++ b/app/serializers/README.md @@ -0,0 +1,325 @@ +# Serializers + +This is a documentation for classes located in `app/serializers` directory. + +In GitLab, we use [grape-entities][grape-entity-project], accompanied by a +serializer, to convert a Ruby object to its JSON representation. + +Serializers are typically used in controllers to build a JSON response +that is usually consumed by a frontend code. + +## Why using a serializer is important? + +Using serializers, instead of `to_json` method, has several benefits: + +* it helps to prevent exposure of a sensitive data stored in the database +* it makes it easier to test what should and should not be exposed +* it makes it easier to reuse serialization entities that are building blocks +* it makes it easier to move complexity from controllers to easily testable + classes +* it encourages hiding complexity behind intentions-revealing interfaces +* it makes it easier to take care about serialization performance concerns +* it makes it easier to reduce merge conflicts between CE -> EE +* it makes it easier to benefit from domain driven development techniques + +## What is a serializer? + +A serializer is a class that encapsulates all business rules for building a +JSON response using serialization entities. + +It is designed to be testable and to support passing additional context from +the controller. + +## What is a serialization entity? + +Entities are lightweight structures that allow to represent domain models +in a consistent and abstracted way, and reuse them as building blocks to +create a payload. + +Entities located in `app/serializers` are usually derived from a +[`Grape::Entity`][grape-entity-class] class. + +Serialization entities that do require to have a knowledge about specific +elements of the request, need to mix `RequestAwareEntity` in. + +A serialization entity usually maps a domain model class into its JSON +representation. It rarely happens that a serialization entity exists without +a corresponding domain model class. As an example, we have an `Issue` class and +a corresponding `IssueSerializer`. + +Serialization entites are designed to reuse other serialization entities, which +is a convenient way to create a multi-level JSON representation of a piece of +a domain model you want to serialize. + +See [documentation for Grape Entites][grape-entity-readme] for more details. + +## How to implement a serializer? + +### Base implementation + +In order to effectively implement a serializer it is necessary to create a new +class in `app/serializers`. See existing serializers as an example. + +A new serializer should inherit from a `BaseSerializer` class. It is necessary +to specify which serialization entity will be used to serialize a resource. + +```ruby +class MyResourceSerializer < BaseSerialize + entity MyResourceEntity +end +``` + +The example above shows how a most simple serializer can look like. + +Given that the entity `MyResourceEntity` exists, you can now use +`MyResourceSerializer` in the controller by creating an instance of it, and +calling `MyResourceSerializer#represent(resource)` method. + +Note that a `resource` can be either a single object, an array of objects or an +`ActiveRecord::Relation` object. A serialization entity should be smart enough +to accurately represent each of these. + +It should not be necessary to use `Enumerable#map`, and it should be avoided +from the performance reasons. + +### Choosing what gets serialized + +It often happens that you might want to use the same serializer in many places, +but sometimes the intention is to only expose a small subset of object's +attributes in one place, and a different subset in another. + +`BaseSerializer#represent(resource, opts = {})` method can take an additional +hash argument, `opts`, that defines what is going to be serialized. + +`BaseSerializer` will pass these options to a serialization entity. See +how it is [documented in the upstream project][grape-entity-only]. + +With this approach you can extend the serializer to respond to methods that will +create a JSON response according to your needs. + +```ruby +class PipelineSerializer < BaseSerializer + entity PipelineEntity + + def represent_details(resource) + represent(resource, only: [:details]) + end + + def represent_status(resource) + represent(resource, only: [:status]) + end +end +``` + +It is possible to use `only` and `except` keywords. Both keywords do support +nested attributes, like `except: [:id, { user: [:id] }]`. + +Passing `only` and `except` to the `represent` method from a controller is +possible, but it defies principles of encapsulation and testability, and it is +better to avoid it, and to add a specific method to the serializer instead. + +### Reusing serialization entities from the API + +Public API in GitLab is implemented using [Grape][grape-project]. + +Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes. +This means that it is possible to reuse these classes to implement internal +serializers. + +You can either use such entity directly: + +```ruby +class MyResourceSerializer < BaseSerializer + entity API::Entities::SomeEntity +end +``` + +Or derive a new serialization entity class from it: + +```ruby +class MyEntity < API::Entities::SomeEntity + include RequestAwareEntity + + unexpose :something +end +``` + +It might be a good idea to write specs for entities that do inherit from +the API, because when API payloads are changed / extended, it is easy to forget +about the impact on the internal API through a serializer that reuses API +entities. + +It is usually safe to do that, because API entities rarely break backward +compatibility, but additional exposure may have a performance impact when API +gets extended significantly. Write tests that check if only necessary data is +exposed. + +## How to write tests for a serializer? + +Like every other class in the project, creating a serializer warrants writing +tests for it. + +It is usually a good idea to test each public method in the serializer against +a valid payload. `BaseSerializer#represent` returns a hash, so it is possible +to use usual RSpec matchers like `include`. + +Sometimes, when the payload is large, it makes sense to validate it entirely +using `match_response_schema` matcher along with a new fixture that can be +stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema` +gem, which is quite flexible, see a [documentation][json-schema-gem] for it. + +## How to use a serializer in a controller? + +Once a new serializer is implemented, it is possible to use it in a controller. + +Create an instance of the serializer and render the response. + +```ruby +def index + format.json do + render json: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources) + nd +end +``` + +If it is necessary to include additional information in the payload, it is +possible to extend what is going to be rendered, the usual way: + +```ruby +def index + format.json do + render json: { + resources: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources), + count: @project.resources.count + } + nd +end +``` + +Note that in these examples an additional context is being passed to the +serializer (`current_user: @current_user`). + +## How to pass an additional context from the controller? + +It is possible to pass an additional context from a controller to a +serializer and each serialization entity that is used in the process. + +Serialization entities that do require an additional context have +`RequestAwareEntity` concern mixed in. This piece of the code exposes a method +called `request` in every serialization entity that is instantiated during +serialization. + +An object returned by this method is an instance of `EntityRequest`, which +behaves like an `OpenStruct` object, with the difference that it will raise +an error if an unknown method is called. + +In other words, in the previous example, `request` method will return an +instance of `EntityRequest` that responds to `current_user` method. It will be +available in every serialization entity instantiated by `MyResourceSerializer`. + +`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be +refactored soon. Please avoid passing an additional context that is not +required by a serialization entity. + +At the moment, the context that is passed to entities most often is +`current_user` and `project`. + +## How is this related to using presenters? + +Payload created by a serializer is usually a representation of the backed code, +combined with the current request data. Therefore, technically, serializers +are presenters that create payload consumed by a frontend code, usually Vue +components. + +In GitLab, it is possible to use [presenters][presenters-readme], but +`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898]. + +It is possible to use presenters when serializer is used to represent only +a single object. It is not supported when `ActiveRecord::Relation` is being +serialized. + +```ruby +MyObjectSerializer.new.represent(object.present) +``` + +## Best practices + +1. Do not invoke a serializer from within a serialization entity. + + If you need to use a serializer from within a serialization entity, it is + possible that you are missing a class for an important domain concept. + + Consider creating a new domain class and a corresponding serialization + entity for it. + +1. Use only one approach to switch behavior of the serializer. + + It is possible to use a few approaches to switch a behavior of the + serializer. Most common are using a [Fluent Interface][fluent-interface] + and creating a separate `represent_something` methods. + + Whatever you choose, it might be better to use only one approach at a time. + +1. Do not forget about creating specs for serialization entities. + + Writing tests for the serializer indeed does cover testing a behavior of + serialization entities that the serializer instantiates. However it might + be a good idea to write separate tests for entities as well, because these + are meant to be reused in different serializers, and a serializer can + change a behavior of a serialization entity. + +1. Use `ActiveRecord::Relation` where possible + + Using an `ActiveRecord::Relation` might help from the performance perspective. + +1. Be diligent about passing an additional context from the controller. + + Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack + of high-level mechanism. It is meant to be refactored, and current + implementation is error prone. Imagine the situation that one serialization + entity requires `request.user` attribute, but the second one wants + `request.current_user`. When it happens that these two entities are used in + the same serialization request, you might need to pass both parameters to + the serializer, which is obviously not a perfect situation. + + When in doubt, pass only `current_user` and `project` if these are required. + +1. Keep performance concerns in mind + + Using a serializer incorrectly can have significant impact on the + performance. + + Because serializers are technically presenters, it is often necessary + to calculate, for example, paths to various controller-actions. + Since using URL helpers usually involve passing `project` and `namespace` + adding `includes(project: :namespace)` in the serializer, can help to avoid + N+1 queries. + + Also, try to avoid using `Enumerable#map` or other methods that will + execute a database query eagerly. + +1. Avoid passing `only` and `except` from the controller. +1. Write tests checking for N+1 queries. +1. Write controller tests for actions / formats using serializers. +1. Write tests that check if only necessary data is exposed. +1. Write tests that check if no sensitive data is exposed. + +## Future + +* [Next iteration of serializers][issue-27569] + +[grape-project]: http://www.ruby-grape.org +[grape-entity-project]: https://github.com/ruby-grape/grape-entity +[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md +[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb +[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want +[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md +[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface +[json-schema-gem]: https://github.com/ruby-json-schema/json-schema +[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045 +[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898 +[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569 diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index 69bf693de8d..564612202b5 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -2,6 +2,7 @@ class AnalyticsStageEntity < Grape::Entity include EntityDateHelper expose :title + expose :name expose :legend expose :description diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index 91803ec07f5..9c37afd53e1 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -1,7 +1,4 @@ class AnalyticsSummaryEntity < Grape::Entity expose :value, safe: true - - expose :title do |object| - object.title.pluralize(object.value) - end + expose :title end diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb new file mode 100644 index 00000000000..d75a83d0fa5 --- /dev/null +++ b/app/serializers/deploy_key_entity.rb @@ -0,0 +1,14 @@ +class DeployKeyEntity < Grape::Entity + expose :id + expose :user_id + expose :title + expose :fingerprint + expose :can_push + expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned + expose :almost_orphaned?, as: :almost_orphaned + expose :created_at + expose :updated_at + expose :projects, using: ProjectEntity do |deploy_key| + deploy_key.projects.select { |project| options[:user].can?(:read_project, project) } + end +end diff --git a/app/serializers/deploy_key_serializer.rb b/app/serializers/deploy_key_serializer.rb new file mode 100644 index 00000000000..8f849eb88b7 --- /dev/null +++ b/app/serializers/deploy_key_serializer.rb @@ -0,0 +1,3 @@ +class DeployKeySerializer < BaseSerializer + entity DeployKeyEntity +end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index d610fbe0c8a..8b3de1bed0f 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -18,8 +18,10 @@ class DeploymentEntity < Grape::Entity end end + expose :created_at expose :tag expose :last? + expose :user, using: UserEntity expose :commit, using: CommitEntity expose :deployable, using: BuildEntity diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb new file mode 100644 index 00000000000..cba5c3f311f --- /dev/null +++ b/app/serializers/deployment_serializer.rb @@ -0,0 +1,8 @@ +class DeploymentSerializer < BaseSerializer + entity DeploymentEntity + + def represent_concise(resource, opts = {}) + opts[:only] = [:iid, :id, :sha, :created_at, :tag, :last?, :id, ref: [:name]] + represent(resource, opts) + end +end diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index 29aecb50849..65b204d4dd2 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -1,7 +1,6 @@ class IssuableEntity < Grape::Entity expose :id expose :iid - expose :assignee_id expose :author_id expose :description expose :lock_version diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 6429159ebe1..bc4f68710b2 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,6 +1,7 @@ class IssueEntity < IssuableEntity expose :branch_name expose :confidential + expose :assignees, using: API::Entities::UserBasic expose :due_date expose :moved_to_id expose :project_id diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb new file mode 100644 index 00000000000..a4d3737429c --- /dev/null +++ b/app/serializers/job_group_entity.rb @@ -0,0 +1,16 @@ +class JobGroupEntity < Grape::Entity + include RequestAwareEntity + + expose :name + expose :size + expose :detailed_status, as: :status, with: StatusEntity + expose :jobs, with: BuildEntity + + private + + alias_method :group, :object + + def detailed_status + group.detailed_status(request.user) + end +end diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb index 304fd9de08f..ad565654342 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -6,6 +6,7 @@ class LabelEntity < Grape::Entity expose :group_id expose :project_id expose :template + expose :text_color expose :created_at expose :updated_at end diff --git a/app/serializers/label_serializer.rb b/app/serializers/label_serializer.rb new file mode 100644 index 00000000000..ad6ba8c46c9 --- /dev/null +++ b/app/serializers/label_serializer.rb @@ -0,0 +1,7 @@ +class LabelSerializer < BaseSerializer + entity LabelEntity + + def represent_appearance(resource) + represent(resource, { only: [:id, :title, :color, :text_color] }) + end +end diff --git a/app/serializers/merge_request_create_entity.rb b/app/serializers/merge_request_create_entity.rb new file mode 100644 index 00000000000..11234313293 --- /dev/null +++ b/app/serializers/merge_request_create_entity.rb @@ -0,0 +1,7 @@ +class MergeRequestCreateEntity < Grape::Entity + expose :iid + + expose :url do |merge_request| + Gitlab::UrlBuilder.build(merge_request) + end +end diff --git a/app/serializers/merge_request_create_serializer.rb b/app/serializers/merge_request_create_serializer.rb new file mode 100644 index 00000000000..08daf473319 --- /dev/null +++ b/app/serializers/merge_request_create_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestCreateSerializer < BaseSerializer + entity MergeRequestCreateEntity +end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 5f80ab397a9..453ba52b892 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,4 +1,5 @@ class MergeRequestEntity < IssuableEntity + expose :assignee_id expose :in_progress_merge_commit_sha expose :locked_at expose :merge_commit_sha diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb new file mode 100644 index 00000000000..a471a7e6a88 --- /dev/null +++ b/app/serializers/project_entity.rb @@ -0,0 +1,14 @@ +class ProjectEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :name + + expose :full_path do |project| + namespace_project_path(project.namespace, project) + end + + expose :full_name do |project| + project.full_name + end +end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 7a047bdc712..97ced8730ed 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -7,9 +7,11 @@ class StageEntity < Grape::Entity "#{stage.name}: #{detailed_status.label}" end - expose :detailed_status, - as: :status, - with: StatusEntity + expose :groups, + if: -> (_, opts) { opts[:grouped] }, + with: JobGroupEntity + + expose :detailed_status, as: :status, with: StatusEntity expose :path do |stage| namespace_project_pipeline_path( diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index 188c3747f18..3e40ecf1c1c 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -12,4 +12,11 @@ class StatusEntity < Grape::Entity ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) end + + expose :action, if: -> (status, _) { status.has_action? } do + expose :action_icon, as: :icon + expose :action_title, as: :title + expose :action_path, as: :path + expose :action_method, as: :method + end end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 60891cbb255..40ff9b8b867 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,10 +7,14 @@ module Issuable ids = params.delete(:issuable_ids).split(",") items = model_class.where(id: ids) - %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key| + %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key| params.delete(key) unless params[key].present? end + if params[:assignee_ids] == [IssuableFinder::NONE.to_s] + params[:assignee_ids] = [] + end + items.each do |issuable| next unless can?(current_user, :"update_#{type}", issuable) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index b071a398481..c1e532b504a 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -1,11 +1,6 @@ class IssuableBaseService < BaseService private - def create_assignee_note(issuable) - SystemNoteService.change_assignee( - issuable, issuable.project, current_user, issuable.assignee) - end - def create_milestone_note(issuable) SystemNoteService.change_milestone( issuable, issuable.project, current_user, issuable.milestone) @@ -24,6 +19,10 @@ class IssuableBaseService < BaseService issuable, issuable.project, current_user, old_title) end + def create_description_change_note(issuable) + SystemNoteService.change_description(issuable, issuable.project, current_user) + end + def create_branch_change_note(issuable, branch_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, @@ -53,6 +52,7 @@ class IssuableBaseService < BaseService params.delete(:add_label_ids) params.delete(:remove_label_ids) params.delete(:label_ids) + params.delete(:assignee_ids) params.delete(:assignee_id) params.delete(:due_date) end @@ -77,7 +77,7 @@ class IssuableBaseService < BaseService def assignee_can_read?(issuable, assignee_id) new_assignee = User.find_by_id(assignee_id) - return false unless new_assignee.present? + return false unless new_assignee ability_name = :"read_#{issuable.to_ability_name}" resource = issuable.persisted? ? issuable : project @@ -207,6 +207,7 @@ class IssuableBaseService < BaseService filter_params(issuable) old_labels = issuable.labels.to_a old_mentioned_users = issuable.mentioned_users.to_a + old_assignees = issuable.assignees.to_a label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) @@ -214,6 +215,10 @@ class IssuableBaseService < BaseService if issuable.changed? || params.present? issuable.assign_attributes(params.merge(updated_by: current_user)) + if has_title_or_description_changed?(issuable) + issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user) + end + before_update(issuable) if issuable.with_transaction_returning_status { issuable.save } @@ -222,7 +227,13 @@ class IssuableBaseService < BaseService handle_common_system_notes(issuable, old_labels: old_labels) end - handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) + handle_changes( + issuable, + old_labels: old_labels, + old_mentioned_users: old_mentioned_users, + old_assignees: old_assignees + ) + after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') @@ -236,6 +247,10 @@ class IssuableBaseService < BaseService old_label_ids.sort != new_label_ids.sort end + def has_title_or_description_changed?(issuable) + issuable.title_changed? || issuable.description_changed? + end + def change_state(issuable) case params.delete(:state_event) when 'reopen' @@ -272,7 +287,7 @@ class IssuableBaseService < BaseService end end - def has_changes?(issuable, old_labels: []) + def has_changes?(issuable, old_labels: [], old_assignees: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| @@ -281,7 +296,9 @@ class IssuableBaseService < BaseService labels_changed = issuable.labels != old_labels - attrs_changed || labels_changed + assignees_changed = issuable.assignees != old_assignees + + attrs_changed || labels_changed || assignees_changed end def handle_common_system_notes(issuable, old_labels: []) @@ -289,6 +306,10 @@ class IssuableBaseService < BaseService create_title_change_note(issuable, issuable.previous_changes['title'].first) end + if issuable.previous_changes.include?('description') + create_description_change_note(issuable) + end + if issuable.previous_changes.include?('description') && issuable.tasks? create_task_status_note(issuable) end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index ee1b40db718..34199eb5d13 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -9,11 +9,33 @@ module Issues private + def create_assignee_note(issue, old_assignees) + SystemNoteService.change_issue_assignees( + issue, issue.project, current_user, old_assignees) + end + def execute_hooks(issue, action = 'open') issue_data = hook_data(issue, action) hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks issue.project.execute_hooks(issue_data, hooks_scope) issue.project.execute_services(issue_data, hooks_scope) end + + def filter_assignee(issuable) + return if params[:assignee_ids].blank? + + # The number of assignees is limited by one for GitLab CE + params[:assignee_ids] = params[:assignee_ids][0, 1] + + assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } + + if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] + params[:assignee_ids] = [] + elsif assignee_ids.any? + params[:assignee_ids] = assignee_ids + else + params.delete(:assignee_ids) + end + end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b7fe5cb168b..cd9f9a4a16e 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -12,8 +12,12 @@ module Issues spam_check(issue, current_user) end - def handle_changes(issue, old_labels: [], old_mentioned_users: []) - if has_changes?(issue, old_labels: old_labels) + def handle_changes(issue, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + old_assignees = options[:old_assignees] || [] + + if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -26,9 +30,9 @@ module Issues create_milestone_note(issue) end - if issue.previous_changes.include?('assignee_id') - create_assignee_note(issue) - notification_service.reassigned_issue(issue, current_user) + if issue.assignees != old_assignees + create_assignee_note(issue, old_assignees) + notification_service.reassigned_issue(issue, current_user, old_assignees) todo_service.reassigned_issue(issue, current_user) end diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index 1711be7211c..a85b9465c84 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -26,15 +26,22 @@ module Members def unassign_issues_and_merge_requests(member) if member.is_a?(GroupMember) - IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). - execute. - update_all(assignee_id: nil) + issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). + execute.pluck(:id) + + IssueAssignee.destroy_all(issue_id: issue_ids, user_id: member.user_id) + MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). execute. update_all(assignee_id: nil) else project = member.source - project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil) + + IssueAssignee.destroy_all( + user_id: member.user_id, + issue_id: project.issues.opened.assigned_to(member.user).select(:id) + ) + project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) member.user.update_cache_counts end diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb index 066efa1acc3..8c6c4841020 100644 --- a/app/services/merge_requests/assign_issues_service.rb +++ b/app/services/merge_requests/assign_issues_service.rb @@ -4,7 +4,7 @@ module MergeRequests @assignable_issues ||= begin if current_user == merge_request.author closes_issues.select do |issue| - !issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue) + !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue) end else [] @@ -14,7 +14,7 @@ module MergeRequests def execute assignable_issues.each do |issue| - Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue) + Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue) end { diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 582d5c47b66..3542a41ac83 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -38,6 +38,11 @@ module MergeRequests private + def create_assignee_note(merge_request) + SystemNoteService.change_assignee( + merge_request, merge_request.project, current_user, merge_request.assignee) + end + # Returns all origin and fork merge requests from `@project` satisfying passed arguments. def merge_requests_for(source_branch, mr_states: [:opened, :reopened]) MergeRequest diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb new file mode 100644 index 00000000000..738cedbaed7 --- /dev/null +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -0,0 +1,54 @@ +module MergeRequests + class CreateFromIssueService < MergeRequests::CreateService + def execute + return error('Invalid issue iid') unless issue_iid.present? && issue.present? + + result = CreateBranchService.new(project, current_user).execute(branch_name, ref) + return result if result[:status] == :error + + SystemNoteService.new_issue_branch(issue, project, current_user, branch_name) + + new_merge_request = create(merge_request) + + if new_merge_request.valid? + success(new_merge_request) + else + error(new_merge_request.errors) + end + end + + private + + def issue_iid + @isssue_iid ||= params.delete(:issue_iid) + end + + def issue + @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid) + end + + def branch_name + @branch_name ||= issue.to_branch_name + end + + def ref + project.default_branch || 'master' + end + + def merge_request + MergeRequests::BuildService.new(project, current_user, merge_request_params).execute + end + + def merge_request_params + { + source_project_id: project.id, + source_branch: branch_name, + target_project_id: project.id + } + end + + def success(merge_request) + super().merge(merge_request: merge_request) + end + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index ab7fcf3b6e2..5c843a258fb 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -21,7 +21,10 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) + def handle_changes(merge_request, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index ea7cacc956c..abf25bb778b 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -3,8 +3,8 @@ module Notes def execute in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id) - if project && in_reply_to_discussion_id.present? - discussion = project.notes.find_discussion(in_reply_to_discussion_id) + if in_reply_to_discussion_id.present? + discussion = find_discussion(in_reply_to_discussion_id) unless discussion note = Note.new @@ -21,5 +21,19 @@ module Notes note end + + def find_discussion(discussion_id) + if project + project.notes.find_discussion(discussion_id) + else + # only PersonalSnippets can have discussions without project association + discussion = Note.find_discussion(discussion_id) + noteable = discussion.noteable + + return nil unless noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) + + discussion + end + end end end diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 8bb995158de..988bd0a7cdb 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -19,9 +19,14 @@ class NotificationRecipientService # Re-assign is considered as a mention of the new assignee so we add the # new assignee to the list of recipients after we rejected users with # the "on mention" notification level - if [:reassign_merge_request, :reassign_issue].include?(custom_action) + case custom_action + when :reassign_merge_request recipients << previous_assignee if previous_assignee recipients << target.assignee + when :reassign_issue + previous_assignees = Array(previous_assignee) + recipients.concat(previous_assignees) + recipients.concat(target.assignees) end recipients = reject_muted_users(recipients) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 6b186263bd1..c65c66d7150 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -66,8 +66,25 @@ class NotificationService # * issue new assignee if their notification level is not Disabled # * users with custom level checked with "reassign issue" # - def reassigned_issue(issue, current_user) - reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email) + def reassigned_issue(issue, current_user, previous_assignees = []) + recipients = NotificationRecipientService.new(issue.project).build_recipients( + issue, + current_user, + action: "reassign", + previous_assignee: previous_assignees + ) + + previous_assignee_ids = previous_assignees.map(&:id) + + recipients.each do |recipient| + mailer.send( + :reassigned_issue_email, + recipient.id, + issue.id, + previous_assignee_ids, + current_user.id + ).deliver_later + end end # When we add labels to an issue we should send an email to: @@ -367,10 +384,10 @@ class NotificationService end def previous_record(object, attribute) - if object && attribute - if object.previous_changes.include?(attribute) - object.previous_changes[attribute].first - end + return unless object && attribute + + if object.previous_changes.include?(attribute) + object.previous_changes[attribute].first end end end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb new file mode 100644 index 00000000000..10d45bbf73c --- /dev/null +++ b/app/services/preview_markdown_service.rb @@ -0,0 +1,45 @@ +class PreviewMarkdownService < BaseService + def execute + text, commands = explain_slash_commands(params[:text]) + users = find_user_references(text) + + success( + text: text, + users: users, + commands: commands.join(' ') + ) + end + + private + + def explain_slash_commands(text) + return text, [] unless %w(Issue MergeRequest).include?(commands_target_type) + + slash_commands_service = SlashCommands::InterpretService.new(project, current_user) + slash_commands_service.explain(text, find_commands_target) + end + + def find_user_references(text) + extractor = Gitlab::ReferenceExtractor.new(project, current_user) + extractor.analyze(text, author: current_user) + extractor.users.map(&:username) + end + + def find_commands_target + if commands_target_id.present? + finder = commands_target_type == 'Issue' ? IssuesFinder : MergeRequestsFinder + finder.new(current_user, project_id: project.id).find(commands_target_id) + else + collection = commands_target_type == 'Issue' ? project.issues : project.merge_requests + collection.build + end + end + + def commands_target_type + params[:slash_commands_target_type] + end + + def commands_target_id + params[:slash_commands_target_id] + end +end diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb index 3cf4264ce9b..121385afca3 100644 --- a/app/services/projects/enable_deploy_key_service.rb +++ b/app/services/projects/enable_deploy_key_service.rb @@ -4,7 +4,10 @@ module Projects key = accessible_keys.find_by(id: params[:key_id] || params[:id]) return unless key - project.deploy_keys << key + unless project.deploy_keys.include?(key) + project.deploy_keys << key + end + key end diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb new file mode 100644 index 00000000000..a8ef2108492 --- /dev/null +++ b/app/services/projects/propagate_service_template.rb @@ -0,0 +1,103 @@ +module Projects + class PropagateServiceTemplate + BATCH_SIZE = 100 + + def self.propagate(*args) + new(*args).propagate + end + + def initialize(template) + @template = template + end + + def propagate + return unless @template.active? + + Rails.logger.info("Propagating services for template #{@template.id}") + + propagate_projects_with_template + end + + private + + def propagate_projects_with_template + loop do + batch = project_ids_batch + + bulk_create_from_template(batch) unless batch.empty? + + break if batch.size < BATCH_SIZE + end + end + + def bulk_create_from_template(batch) + service_list = batch.map do |project_id| + service_hash.values << project_id + end + + Project.transaction do + bulk_insert_services(service_hash.keys << 'project_id', service_list) + run_callbacks(batch) + end + end + + def project_ids_batch + Project.connection.select_values( + <<-SQL + SELECT id + FROM projects + WHERE NOT EXISTS ( + SELECT true + FROM services + WHERE services.project_id = projects.id + AND services.type = '#{@template.type}' + ) + AND projects.pending_delete = false + AND projects.archived = false + LIMIT #{BATCH_SIZE} + SQL + ) + end + + def bulk_insert_services(columns, values_array) + ActiveRecord::Base.connection.execute( + <<-SQL.strip_heredoc + INSERT INTO services (#{columns.join(', ')}) + VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + SQL + ) + end + + def service_hash + @service_hash ||= + begin + template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id') + + template_hash.each_with_object({}) do |(key, value), service_hash| + value = value.is_a?(Hash) ? value.to_json : value + + service_hash[ActiveRecord::Base.connection.quote_column_name(key)] = + ActiveRecord::Base.sanitize(value) + end + end + end + + def run_callbacks(batch) + if active_external_issue_tracker? + Project.where(id: batch).update_all(has_external_issue_tracker: true) + end + + if active_external_wiki? + Project.where(id: batch).update_all(has_external_wiki: true) + end + end + + def active_external_issue_tracker? + @template.issue_tracker? && !@template.default + end + + def active_external_wiki? + @template.type == 'ExternalWikiService' + end + end +end diff --git a/app/services/projects/upload_service.rb b/app/services/projects/upload_service.rb deleted file mode 100644 index be34d4fa9b8..00000000000 --- a/app/services/projects/upload_service.rb +++ /dev/null @@ -1,22 +0,0 @@ -module Projects - class UploadService < BaseService - def initialize(project, file) - @project, @file = project, file - end - - def execute - return nil unless @file && @file.size <= max_attachment_size - - uploader = FileUploader.new(@project) - uploader.store!(@file) - - uploader.to_h - end - - private - - def max_attachment_size - current_application_settings.max_attachment_size.megabytes.to_i - end - end -end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 6aeebc26685..a7e13648b54 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -2,7 +2,7 @@ module SlashCommands class InterpretService < BaseService include Gitlab::SlashCommands::Dsl - attr_reader :issuable, :options + attr_reader :issuable # Takes a text and interprets the commands that are extracted from it. # Returns the content without commands, and hash of changes to be applied to a record. @@ -12,23 +12,21 @@ module SlashCommands @issuable = issuable @updates = {} - opts = { - issuable: issuable, - current_user: current_user, - project: project, - params: params - } - - content, commands = extractor.extract_commands(content, opts) + content, commands = extractor.extract_commands(content, context) + extract_updates(commands, context) + [content, @updates] + end - commands.each do |name, arg| - definition = self.class.command_definitions_by_name[name.to_sym] - next unless definition + # Takes a text and interprets the commands that are extracted from it. + # Returns the content without commands, and array of changes explained. + def explain(content, issuable) + return [content, []] unless current_user.can?(:use_slash_commands) - definition.execute(self, opts, arg) - end + @issuable = issuable - [content, @updates] + content, commands = extractor.extract_commands(content, context) + commands = explain_commands(commands, context) + [content, commands] end private @@ -40,6 +38,9 @@ module SlashCommands desc do "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" end + explanation do + "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.open? && @@ -52,6 +53,9 @@ module SlashCommands desc do "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" end + explanation do + "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.closed? && @@ -62,6 +66,7 @@ module SlashCommands end desc 'Merge (when the pipeline succeeds)' + explanation 'Merges this merge request when the pipeline succeeds.' condition do last_diff_sha = params && params[:merge_request_diff_head_sha] issuable.is_a?(MergeRequest) && @@ -73,6 +78,9 @@ module SlashCommands end desc 'Change title' + explanation do |title_param| + "Changes the title to \"#{title_param}\"." + end params '<New title>' condition do issuable.persisted? && @@ -83,41 +91,70 @@ module SlashCommands end desc 'Assign' + explanation do |users| + "Assigns #{users.map(&:to_reference).to_sentence}." if users.any? + end params '@user' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :assign do |assignee_param| - user = extract_references(assignee_param, :user).first - user ||= User.find_by(username: assignee_param) + parse_params do |assignee_param| + users = extract_references(assignee_param, :user) + + if users.empty? + users = User.where(username: assignee_param.split(' ').map(&:strip)) + end + + users + end + command :assign do |users| + next if users.empty? - @updates[:assignee_id] = user.id if user + if issuable.is_a?(Issue) + @updates[:assignee_ids] = users.map(&:id) + else + @updates[:assignee_id] = users.last.id + end end desc 'Remove assignee' + explanation do + "Removes assignee #{issuable.assignees.first.to_reference}." + end condition do issuable.persisted? && - issuable.assignee_id? && + issuable.assignees.any? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :unassign do - @updates[:assignee_id] = nil + if issuable.is_a?(Issue) + @updates[:assignee_ids] = [] + else + @updates[:assignee_id] = nil + end end desc 'Set milestone' + explanation do |milestone| + "Sets the milestone to #{milestone.to_reference}." if milestone + end params '%"milestone"' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) && project.milestones.active.any? end - command :milestone do |milestone_param| - milestone = extract_references(milestone_param, :milestone).first - milestone ||= project.milestones.find_by(title: milestone_param.strip) - + parse_params do |milestone_param| + extract_references(milestone_param, :milestone).first || + project.milestones.find_by(title: milestone_param.strip) + end + command :milestone do |milestone| @updates[:milestone_id] = milestone.id if milestone end desc 'Remove milestone' + explanation do + "Removes #{issuable.milestone.to_reference(format: :name)} milestone." + end condition do issuable.persisted? && issuable.milestone_id? && @@ -128,6 +165,11 @@ module SlashCommands end desc 'Add label(s)' + explanation do |labels_param| + labels = find_label_references(labels_param) + + "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + end params '~label1 ~"label 2"' condition do available_labels = LabelsFinder.new(current_user, project_id: project.id).execute @@ -147,6 +189,14 @@ module SlashCommands end desc 'Remove all or specific label(s)' + explanation do |labels_param = nil| + if labels_param.present? + labels = find_label_references(labels_param) + "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + else + 'Removes all labels.' + end + end params '~label1 ~"label 2"' condition do issuable.persisted? && @@ -169,6 +219,10 @@ module SlashCommands end desc 'Replace all label(s)' + explanation do |labels_param| + labels = find_label_references(labels_param) + "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + end params '~label1 ~"label 2"' condition do issuable.persisted? && @@ -187,6 +241,7 @@ module SlashCommands end desc 'Add a todo' + explanation 'Adds a todo.' condition do issuable.persisted? && !TodoService.new.todo_exist?(issuable, current_user) @@ -196,6 +251,7 @@ module SlashCommands end desc 'Mark todo as done' + explanation 'Marks todo as done.' condition do issuable.persisted? && TodoService.new.todo_exist?(issuable, current_user) @@ -205,6 +261,9 @@ module SlashCommands end desc 'Subscribe' + explanation do + "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && !issuable.subscribed?(current_user, project) @@ -214,6 +273,9 @@ module SlashCommands end desc 'Unsubscribe' + explanation do + "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.subscribed?(current_user, project) @@ -223,18 +285,23 @@ module SlashCommands end desc 'Set due date' + explanation do |due_date| + "Sets the due date to #{due_date.to_s(:medium)}." if due_date + end params '<in 2 days | this Friday | December 31st>' condition do issuable.respond_to?(:due_date) && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :due do |due_date_param| - due_date = Chronic.parse(due_date_param).try(:to_date) - + parse_params do |due_date_param| + Chronic.parse(due_date_param).try(:to_date) + end + command :due do |due_date| @updates[:due_date] = due_date if due_date end desc 'Remove due date' + explanation 'Removes the due date.' condition do issuable.persisted? && issuable.respond_to?(:due_date) && @@ -245,8 +312,11 @@ module SlashCommands @updates[:due_date] = nil end - desc do - "Toggle the Work In Progress status" + desc 'Toggle the Work In Progress status' + explanation do + verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks' + noun = issuable.to_ability_name.humanize(capitalize: false) + "#{verb} this #{noun} as Work In Progress." end condition do issuable.persisted? && @@ -257,45 +327,72 @@ module SlashCommands @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' end - desc 'Toggle emoji reward' + desc 'Toggle emoji award' + explanation do |name| + "Toggles :#{name}: emoji award." if name + end params ':emoji:' condition do issuable.persisted? end - command :award do |emoji| - name = award_emoji_name(emoji) + parse_params do |emoji_param| + match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern) + match[1] if match + end + command :award do |name| if name && issuable.user_can_award?(current_user, name) @updates[:emoji_award] = name end end desc 'Set time estimate' + explanation do |time_estimate| + time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate) + + "Sets time estimate to #{time_estimate}." if time_estimate + end params '<1w 3d 2h 14m>' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :estimate do |raw_duration| - time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration) - + parse_params do |raw_duration| + Gitlab::TimeTrackingFormatter.parse(raw_duration) + end + command :estimate do |time_estimate| if time_estimate @updates[:time_estimate] = time_estimate end end desc 'Add or substract spent time' + explanation do |time_spent| + if time_spent + if time_spent > 0 + verb = 'Adds' + value = time_spent + else + verb = 'Substracts' + value = -time_spent + end + + "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time." + end + end params '<1h 30m | -1h 30m>' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) end - command :spend do |raw_duration| - time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration) - + parse_params do |raw_duration| + Gitlab::TimeTrackingFormatter.parse(raw_duration) + end + command :spend do |time_spent| if time_spent @updates[:spend_time] = { duration: time_spent, user: current_user } end end desc 'Remove time estimate' + explanation 'Removes time estimate.' condition do issuable.persisted? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) @@ -305,6 +402,7 @@ module SlashCommands end desc 'Remove spent time' + explanation 'Removes spent time.' condition do issuable.persisted? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) @@ -318,19 +416,28 @@ module SlashCommands params '@user' command :cc - desc 'Defines target branch for MR' + desc 'Define target branch for MR' + explanation do |branch_name| + "Sets target branch to #{branch_name}." + end params '<Local branch name>' condition do issuable.respond_to?(:target_branch) && (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) || issuable.new_record?) end - command :target_branch do |target_branch_param| - branch_name = target_branch_param.strip + parse_params do |target_branch_param| + target_branch_param.strip + end + command :target_branch do |branch_name| @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name) end desc 'Move issue from one column of the board to another' + explanation do |target_list_name| + label = find_label_references(target_list_name).first + "Moves issue to #{label} column in the board." if label + end params '~"Target column"' condition do issuable.is_a?(Issue) && @@ -352,11 +459,35 @@ module SlashCommands end end + def find_labels(labels_param) + extract_references(labels_param, :label) | + LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute + end + + def find_label_references(labels_param) + find_labels(labels_param).map(&:to_reference) + end + def find_label_ids(labels_param) - label_ids_by_reference = extract_references(labels_param, :label).map(&:id) - labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id) + find_labels(labels_param).map(&:id) + end - label_ids_by_reference | labels_ids_by_name + def explain_commands(commands, opts) + commands.map do |name, arg| + definition = self.class.definition_by_name(name) + next unless definition + + definition.explain(self, opts, arg) + end.compact + end + + def extract_updates(commands, opts) + commands.each do |name, arg| + definition = self.class.definition_by_name(name) + next unless definition + + definition.execute(self, opts, arg) + end end def extract_references(arg, type) @@ -366,9 +497,13 @@ module SlashCommands ext.references(type) end - def award_emoji_name(emoji) - match = emoji.match(Banzai::Filter::EmojiFilter.emoji_pattern) - match[1] if match + def context + { + issuable: issuable, + current_user: current_user, + project: project, + params: params + } end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index c9e25c7aaa2..174e7c6e95b 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -49,6 +49,44 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) end + # Called when the assignees of an Issue is changed or removed + # + # issue - Issue object + # project - Project owning noteable + # author - User performing the change + # assignees - Users being assigned, or nil + # + # Example Note text: + # + # "removed all assignees" + # + # "assigned to @user1 additionally to @user2" + # + # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5" + # + # "assigned to @user1 and @user2" + # + # Returns the created Note object + def change_issue_assignees(issue, project, author, old_assignees) + body = + if issue.assignees.any? && old_assignees.any? + unassigned_users = old_assignees - issue.assignees + added_users = issue.assignees.to_a - old_assignees + + text_parts = [] + text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any? + text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? + + text_parts.join(' and ') + elsif old_assignees.any? + "removed all assignees" + elsif issue.assignees.any? + "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" + end + + create_note(NoteSummary.new(issue, project, author, body, action: 'assignee')) + end + # Called when one or more labels on a Noteable are added and/or removed # # noteable - Noteable object @@ -261,6 +299,23 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end + # Called when the description of a Noteable is changed + # + # noteable - Noteable object that responds to `description` + # project - Project owning noteable + # author - User performing the change + # + # Example Note text: + # + # "changed the description" + # + # Returns the created Note object + def change_description(noteable, project, author) + body = 'changed the description' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) + end + # Called when the confidentiality changes # # issue - Issue object diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 8ae61694b50..322c6286365 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -251,9 +251,9 @@ class TodoService end def create_assignment_todo(issuable, author) - if issuable.assignee + if issuable.assignees.any? attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED) - create_todos(issuable.assignee, attributes) + create_todos(issuable.assignees, attributes) end end diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb new file mode 100644 index 00000000000..6c5b2baff41 --- /dev/null +++ b/app/services/upload_service.rb @@ -0,0 +1,20 @@ +class UploadService + def initialize(model, file, uploader_class = FileUploader) + @model, @file, @uploader_class = model, file, uploader_class + end + + def execute + return nil unless @file && @file.size <= max_attachment_size + + uploader = @uploader_class.new(@model) + uploader.store!(@file) + + uploader.to_h + end + + private + + def max_attachment_size + current_application_settings.max_attachment_size.megabytes.to_i + end +end diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb index e84944ed411..3e36ec91205 100644 --- a/app/uploaders/artifact_uploader.rb +++ b/app/uploaders/artifact_uploader.rb @@ -30,8 +30,4 @@ class ArtifactUploader < GitlabUploader def filename file.try(:filename) end - - def exists? - file.try(:exists?) - end end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index d2783ce5b2f..7e94218c23d 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -26,11 +26,11 @@ class FileUploader < GitlabUploader File.join(CarrierWave.root, base_dir, model.path_with_namespace) end - attr_accessor :project + attr_accessor :model attr_reader :secret - def initialize(project, secret = nil) - @project = project + def initialize(model, secret = nil) + @model = model @secret = secret || generate_secret end @@ -38,10 +38,6 @@ class FileUploader < GitlabUploader File.join(dynamic_path_segment, @secret) end - def model - project - end - def relative_path self.file.path.sub("#{dynamic_path_segment}/", '') end diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index d662ba6820c..e0a6c9b4067 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -33,4 +33,8 @@ class GitlabUploader < CarrierWave::Uploader::Base def relative_path self.file.path.sub("#{root}/", '') end + + def exists? + file.try(:exists?) + end end diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index faab539b8e0..95a891111e1 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -9,10 +9,6 @@ class LfsObjectUploader < GitlabUploader "#{Gitlab.config.lfs.storage_path}/tmp/cache" end - def exists? - file.try(:exists?) - end - def filename model.oid[4..-1] end diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb new file mode 100644 index 00000000000..969b0a20d38 --- /dev/null +++ b/app/uploaders/personal_file_uploader.rb @@ -0,0 +1,15 @@ +class PersonalFileUploader < FileUploader + def self.dynamic_path_segment(model) + File.join(CarrierWave.root, model_path(model)) + end + + private + + def secure_url + File.join(self.class.model_path(model), secret, file.filename) + end + + def self.model_path(model) + File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s) + end +end diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb new file mode 100644 index 00000000000..d992b0c3725 --- /dev/null +++ b/app/validators/dynamic_path_validator.rb @@ -0,0 +1,215 @@ +# DynamicPathValidator +# +# Custom validator for GitLab path values. +# These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project` +# +# Values are checked for formatting and exclusion from a list of reserved path +# names. +class DynamicPathValidator < ActiveModel::EachValidator + # All routes that appear on the top level must be listed here. + # This will make sure that groups cannot be created with these names + # as these routes would be masked by the paths already in place. + # + # Example: + # /api/api-project + # + # the path `api` shouldn't be allowed because it would be masked by `api/*` + # + TOP_LEVEL_ROUTES = %w[ + - + .well-known + abuse_reports + admin + all + api + assets + autocomplete + ci + dashboard + explore + files + groups + health_check + help + hooks + import + invites + issues + jwt + koding + member + merge_requests + new + notes + notification_settings + oauth + profile + projects + public + repository + robots.txt + s + search + sent_notifications + services + snippets + teams + u + unicorn_test + unsubscribes + uploads + users + ].freeze + + # This list should contain all words following `/*namespace_id/:project_id` in + # routes that contain a second wildcard. + # + # Example: + # /*namespace_id/:project_id/badges/*ref/build + # + # If `badges` was allowed as a project/group name, we would not be able to access the + # `badges` route for those projects: + # + # Consider a namespace with path `foo/bar` and a project called `badges`. + # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg` + # + # When accessing this path the route would be matched to the `badges` path + # with the following params: + # - namespace_id: `foo` + # - project_id: `bar` + # - ref: `badges/master` + # + # Failing to find the project, this would result in a 404. + # + # By rejecting `badges` the router can _count_ on the fact that `badges` will + # be preceded by the `namespace/project`. + WILDCARD_ROUTES = %w[ + badges + blame + blob + builds + commits + create + create_dir + edit + environments/folders + files + find_file + gitlab-lfs/objects + info/lfs/objects + new + preview + raw + refs + tree + update + wikis + ].freeze + + # These are all the paths that follow `/groups/*id/ or `/groups/*group_id` + # We need to reject these because we have a `/groups/*id` page that is the same + # as the `/*id`. + # + # If we would allow a subgroup to be created with the name `activity` then + # this group would not be accessible through `/groups/parent/activity` since + # this would map to the activity-page of it's parent. + GROUP_ROUTES = %w[ + activity + analytics + audit_events + avatar + edit + group_members + hooks + issues + labels + ldap + ldap_group_links + merge_requests + milestones + notification_setting + pipeline_quota + projects + subgroups + ].freeze + + CHILD_ROUTES = (WILDCARD_ROUTES | GROUP_ROUTES).freeze + + def self.without_reserved_wildcard_paths_regex + @without_reserved_wildcard_paths_regex ||= regex_excluding_child_paths(WILDCARD_ROUTES) + end + + def self.without_reserved_child_paths_regex + @without_reserved_child_paths_regex ||= regex_excluding_child_paths(CHILD_ROUTES) + end + + # This is used to validate a full path. + # It doesn't match paths + # - Starting with one of the top level words + # - Containing one of the child level words in the middle of a path + def self.regex_excluding_child_paths(child_routes) + reserved_top_level_words = Regexp.union(TOP_LEVEL_ROUTES) + not_starting_in_reserved_word = %r{\A/?(?!(#{reserved_top_level_words})(/|\z))} + + reserved_child_level_words = Regexp.union(child_routes) + not_containing_reserved_child = %r{(?!\S+/(#{reserved_child_level_words})(/|\z))} + + %r{#{not_starting_in_reserved_word} + #{not_containing_reserved_child} + #{Gitlab::Regex.full_namespace_regex}}x + end + + def self.valid?(path) + path =~ Gitlab::Regex.full_namespace_regex && !full_path_reserved?(path) + end + + def self.full_path_reserved?(path) + path = path.to_s.downcase + _project_part, namespace_parts = path.reverse.split('/', 2).map(&:reverse) + + wildcard_reserved?(path) || child_reserved?(namespace_parts) + end + + def self.child_reserved?(path) + return false unless path + + path !~ without_reserved_child_paths_regex + end + + def self.wildcard_reserved?(path) + return false unless path + + path !~ without_reserved_wildcard_paths_regex + end + + delegate :full_path_reserved?, + :child_reserved?, + to: :class + + def path_reserved_for_record?(record, value) + full_path = record.respond_to?(:full_path) ? record.full_path : value + + # For group paths the entire path cannot contain a reserved child word + # The path doesn't contain the last `_project_part` so we need to validate + # if the entire path. + # Example: + # A *group* with full path `parent/activity` is reserved. + # A *project* with full path `parent/activity` is allowed. + if record.is_a? Group + child_reserved?(full_path) + else + full_path_reserved?(full_path) + end + end + + def validate_each(record, attribute, value) + unless value =~ Gitlab::Regex.namespace_regex + record.errors.add(attribute, Gitlab::Regex.namespace_regex_message) + return + end + + if path_reserved_for_record?(record, value) + record.errors.add(attribute, "#{value} is a reserved name") + end + end +end diff --git a/app/validators/namespace_validator.rb b/app/validators/namespace_validator.rb deleted file mode 100644 index 77ca033e97f..00000000000 --- a/app/validators/namespace_validator.rb +++ /dev/null @@ -1,73 +0,0 @@ -# NamespaceValidator -# -# Custom validator for GitLab namespace values. -# -# Values are checked for formatting and exclusion from a list of reserved path -# names. -class NamespaceValidator < ActiveModel::EachValidator - RESERVED = %w[ - .well-known - admin - all - assets - ci - dashboard - files - groups - help - hooks - issues - merge_requests - new - notes - profile - projects - public - repository - robots.txt - s - search - services - snippets - teams - u - unsubscribes - users - ].freeze - - WILDCARD_ROUTES = %w[tree commits wikis new edit create update logs_tree - preview blob blame raw files create_dir find_file - artifacts graphs refs badges].freeze - - STRICT_RESERVED = (RESERVED + WILDCARD_ROUTES).freeze - - def self.valid?(value) - !reserved?(value) && follow_format?(value) - end - - def self.reserved?(value, strict: false) - if strict - STRICT_RESERVED.include?(value) - else - RESERVED.include?(value) - end - end - - def self.follow_format?(value) - value =~ Gitlab::Regex.namespace_regex - end - - delegate :reserved?, :follow_format?, to: :class - - def validate_each(record, attribute, value) - unless follow_format?(value) - record.errors.add(attribute, Gitlab::Regex.namespace_regex_message) - end - - strict = record.is_a?(Group) && record.parent_id - - if reserved?(value, strict: strict) - record.errors.add(attribute, "#{value} is a reserved name") - end - end -end diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb deleted file mode 100644 index ee2ae65be7b..00000000000 --- a/app/validators/project_path_validator.rb +++ /dev/null @@ -1,35 +0,0 @@ -# ProjectPathValidator -# -# Custom validator for GitLab project path values. -# -# Values are checked for formatting and exclusion from a list of reserved path -# names. -class ProjectPathValidator < ActiveModel::EachValidator - # All project routes with wildcard argument must be listed here. - # Otherwise it can lead to routing issues when route considered as project name. - # - # Example: - # /group/project/tree/deploy_keys - # - # without tree as reserved name routing can match 'group/project' as group name, - # 'tree' as project name and 'deploy_keys' as route. - # - RESERVED = (NamespaceValidator::STRICT_RESERVED - - %w[dashboard help ci admin search notes services assets profile public]).freeze - - def self.valid?(value) - !reserved?(value) - end - - def self.reserved?(value) - RESERVED.include?(value) - end - - delegate :reserved?, to: :class - - def validate_each(record, attribute, value) - if reserved?(value) - record.errors.add(attribute, "#{value} is a reserved name") - end - end -end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 0dc1103eece..4b6628169ef 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -394,8 +394,6 @@ %fieldset %legend Error Reporting and Logging - %p - These settings require a restart to take effect. .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -403,6 +401,7 @@ = f.check_box :sentry_enabled Enable Sentry .help-block + %p This setting requires a restart to take effect. Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com @@ -411,6 +410,21 @@ .col-sm-10 = f.text_field :sentry_dsn, class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :clientside_sentry_enabled do + = f.check_box :clientside_sentry_enabled + Enable Clientside Sentry + .help-block + Sentry can also be used for reporting and logging clientside exceptions. + %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ + + .form-group + = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :clientside_sentry_dsn, class: 'form-control' + %fieldset %legend Repository Storage .form-group diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 840d843f069..89d0bbb7126 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -175,11 +175,7 @@ .panel-body - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting a user has the following effects: - %ul - %li All user content like authored issues, snippets, comments will be removed - - rp = @user.personal_projects.count - - unless rp.zero? - %li #{pluralize rp, 'personal project'} will be removed and cannot be restored + = render 'users/deletion_guidance', user: @user %br = link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" - else diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 549364761e6..78c5b0c1dda 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -3,7 +3,7 @@ .diff-file.file-holder .js-file-title.file-title - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_diff_path(discussion) + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion) .diff-content.code.js-syntax-highlight %table diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 8440fb3d785..38e85168f40 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -20,7 +20,7 @@ = discussion.author.to_reference started a discussion - - url = discussion_diff_path(discussion) + - url = discussion_path(discussion) - if discussion.for_commit? && @noteable != discussion.noteable on - commit = discussion.noteable diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 964473ee3e0..7ba3f3f6c42 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,6 +1,7 @@ .discussion-notes %ul.notes{ data: { discussion_id: discussion.id } } = render partial: "shared/notes/note", collection: discussion.notes, as: :note + .flash-container - if current_user .discussion-reply-holder diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml index 72508b91134..20b7fa471a0 100644 --- a/app/views/errors/omniauth_error.html.haml +++ b/app/views/errors/omniauth_error.html.haml @@ -1,16 +1,15 @@ - content_for(:title, 'Auth Error') -%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') } - %h1 - 422 + .container + = render "shared/errors/graphic_422.svg" %h3 Sign-in using #{@provider} auth failed - %hr - %p Sign-in failed because #{@error}. - %p There are couple of steps you can take: -%ul - %li Try logging in using your email - %li Try logging in using your username - %li If you have forgotten your password, try recovering it using #{ link_to "Password recovery", new_password_path(resource_name) } + %p.light.subtitle Sign-in failed because #{@error}. + + %p Try logging in using your username or email. If you have forgotten your password, try recovering it -%p If none of the options work, try contacting the GitLab administrator. + = link_to "Sign in", new_session_path(:user), class: 'btn primary' + = link_to "Recover password", new_password_path(resource_name), class: 'btn secondary' + + %hr + %p.light If none of the options work, try contacting a GitLab administrator. diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index 8d3aa4d1a74..7c7573862d0 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -26,7 +26,7 @@ .form-group.milestone-description = f.label :description, "Description", class: "control-label" .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: '' } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' .clearfix .error-alert diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 23a88448055..2ed78bb3b65 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -23,10 +23,19 @@ xml.entry do end end - if issue.assignee + if issue.assignees.any? + xml.assignees do + issue.assignees.each do |assignee| + xml.assignee do + xml.name assignee.name + xml.email assignee.public_email + end + end + end + xml.assignee do - xml.name issue.assignee.name - xml.email issue.assignee_public_email + xml.name issue.assignees.first.name + xml.email issue.assignees.first.public_email end end end diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 19473b6ab27..afcc2b6e4f3 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,9 +28,12 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" + = Gon::Base.render_data + = webpack_bundle_tag "runtime" = webpack_bundle_tag "common" = webpack_bundle_tag "main" + = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 36543edc040..7e011ac3e75 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,9 +1,7 @@ !!! 5 -%html{ lang: "en", class: "#{page_class}" } +%html{ lang: I18n.locale, class: "#{page_class}" } = render "layouts/head" %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } } - = Gon::Base.render_data - = render "layouts/header/default", title: header_title = render 'layouts/page', sidebar: sidebar, nav: nav diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 3368a9beb29..52fb46eb8c9 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -3,7 +3,6 @@ = render "layouts/head" %body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } } .page-wrap - = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 7466423a934..ed6731bde95 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -2,7 +2,6 @@ %html{ lang: "en" } = render "layouts/head" %body.ui_charcoal.login-page.application.navless - = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 8ab9747efc5..cdcac7e4264 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -38,7 +38,7 @@ %span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :environments]) do + = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml new file mode 100644 index 00000000000..34bcd2a8b3a --- /dev/null +++ b/app/views/layouts/oauth_error.html.haml @@ -0,0 +1,127 @@ +!!! 5 +%html{ lang: "en" } + %head + %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" } + %title= yield(:title) + :css + body { + color: #666; + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 16px; + } + + .container { + margin: auto 20px; + } + + h3 { + color: #456; + font-size: 22px; + font-weight: bold; + margin-bottom: 6px; + } + + p { + max-width: 470px; + margin: 16px auto; + } + + .subtitle { + margin: 0 auto 20px; + } + + svg { + width: 280px; + height: 280px; + display: block; + margin: 40px auto; + } + + .tv-screen path { + animation: move-lines 1s linear infinite; + } + + + @keyframes move-lines { + 0% {transform: translateY(0)} + 50% {transform: translateY(-10px)} + 100% {transform: translateY(-20px)} + } + + .tv-screen path:nth-child(1) { + animation-delay: .2s + } + + .tv-screen path:nth-child(2) { + animation-delay: .4s + } + + .tv-screen path:nth-child(3) { + animation-delay: .6s + } + + .tv-screen path:nth-child(4) { + animation-delay: .8s + } + + .tv-screen path:nth-child(5) { + animation-delay: 2s + } + + .text-422 { + animation: flicker 1s infinite; + } + + @keyframes flicker { + 0% {opacity: 0.3;} + 10% {opacity: 1;} + 15% {opacity: .3;} + 20% {opacity: .5;} + 25% {opacity: 1;} + } + + .light { + color: #8D8D8D; + } + + hr { + max-width: 600px; + margin: 18px auto; + border: 0; + border-top: 1px solid #EEE; + } + + .btn { + padding: 8px 14px; + border-radius: 3px; + border: 1px solid; + display: inline-block; + text-decoration: none; + margin: 4px 8px; + font-size: 14px; + } + + .primary { + color: #fff; + background-color: #1aaa55; + border-color: #168f48; + } + + .primary:hover { + background-color: #168f48; + } + + .secondary { + color: #1aaa55; + background-color: #fff; + border-color: #1aaa55; + } + + .secondary:hover { + background-color: #f3fff8; + } + +%body + = yield diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index f5e7ea7710d..3f5b0c54e50 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -5,14 +5,9 @@ - content_for :project_javascripts do - project = @target_project || @project - - if @project_wiki && @page - - preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug) - - else - - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project) - if current_user :javascript - window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; - window.preview_markdown_path = "#{preview_markdown_path}"; + window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; - content_for :header_content do .js-dropdown-menu-projects diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index 02ca3ee7a28..98b75cea03f 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,3 +1,9 @@ - header_title "Snippets", snippets_path +- content_for :page_specific_javascripts do + - if @snippet&.persisted? && current_user + :javascript + window.uploads_path = "#{upload_path('personal_snippet', @snippet)}"; + window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}"; + = render template: "layouts/application" diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb deleted file mode 100644 index daf20a226dd..00000000000 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ /dev/null @@ -1,6 +0,0 @@ -Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> - -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> - -Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> - to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index c762578971a..eb5157ccac9 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -2,9 +2,9 @@ %p.details #{link_to @issue.author_name, user_url(@issue.author)} created an issue: -- if @issue.assignee_id.present? +- if @issue.assignees.any? %p - Assignee: #{@issue.assignee_name} + Assignee: #{@issue.assignee_list} - if @issue.description %div diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index ca5c2f2688c..13f1ac08e94 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -2,6 +2,6 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb index 457e94b4800..f19ac3adfc7 100644 --- a/app/views/notify/new_mention_in_issue_email.text.erb +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -2,6 +2,6 @@ You have been mentioned in an issue. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index 498ba8b8365..ee2f40e1683 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -1 +1,10 @@ -= render 'reassigned_issuable_email', issuable: @issue +%p + Assignee changed + - if @previous_assignees.any? + from + %strong= @previous_assignees.map(&:name).to_sentence + to + - if @issue.assignees.any? + %strong= @issue.assignee_list + - else + %strong Unassigned diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index 710253be984..6c357f1074a 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @issue %> +Reassigned Issue <%= @issue.iid %> + +<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%> + to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 2a650130f59..841df872857 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -1 +1,9 @@ -= render 'reassigned_issuable_email', issuable: @merge_request +Reassigned Merge Request #{ @merge_request.iid } + += url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) + +Assignee changed +- if @previous_assignee + from #{@previous_assignee.name} +to += @merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned' diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index b5b4f1ff99a..998a40fefde 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @merge_request %> +Reassigned Merge Request <%= @merge_request.iid %> + +<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> + to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %> diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index d843cacd52d..73f33e69d68 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -118,11 +118,7 @@ - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting an account has the following effects: - %ul - %li All user content like authored issues, snippets, comments will be removed - - rp = current_user.personal_projects.count - - unless rp.zero? - %li #{pluralize rp, 'personal project'} will be removed and cannot be restored + = render 'users/deletion_guidance', user: current_user = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove" - else - if @user.solo_owned_groups.present? diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index c74b3249a13..4a1438aa68e 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -73,6 +73,11 @@ = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), { include_blank: 'Do not show on profile' }, class: "select2" %span.help-block This email will be displayed on your public profile. .form-group + = f.label :preferred_language, class: "label-light" + = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, + {}, class: "select2" + %span.help-block This feature is experimental and translations are not complete yet. + .form-group = f.label :skype, class: "label-light" = f.text_field :skype, class: "form-control" .form-group diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 23e27c1105c..d0698285f84 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -1,3 +1,5 @@ +- referenced_users = local_assigns.fetch(:referenced_users, nil) + .md-area .md-header %ul.nav-links.clearfix @@ -28,9 +30,10 @@ .md-write-holder = yield - .md.md-preview-holder.js-md-preview.hide{ class: (preview_class if defined?(preview_class)) } + .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } } + .referenced-commands.hide - - if defined?(referenced_users) && referenced_users + - if referenced_users .referenced-users.hide %span = icon("exclamation-triangle") diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml index 9e49c93388a..34d5c3b7285 100644 --- a/app/views/projects/artifacts/_tree_directory.html.haml +++ b/app/views/projects/artifacts/_tree_directory.html.haml @@ -3,6 +3,6 @@ %tr.tree-item{ 'data-link' => path_to_directory } %td.tree-item-file-name = tree_icon('folder', '755', directory.name) - %span.str-truncated - = link_to directory.name, path_to_directory + = link_to path_to_directory do + %span.str-truncated= directory.name %td diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index 36fb4c998c9..ce7e25d774b 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,9 +1,10 @@ - path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } + - blob = file.blob %td.tree-item-file-name - = tree_icon('file', '664', file.name) - %span.str-truncated - = link_to file.name, path_to_file + = tree_icon('file', blob.mode, blob.name) + = link_to path_to_file do + %span.str-truncated= blob.name %td - = number_to_human_size(file.metadata[:size], precision: 2) + = number_to_human_size(blob.size, precision: 2) diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index de8c173f26f..9fbb30f7c7c 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,13 +1,23 @@ -- page_title 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' +- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" -.top-block.row-content-block.clearfix - .pull-right - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), - rel: 'nofollow', download: '', class: 'btn btn-default download' do - = icon('download') - Download artifacts archive += render "projects/builds/header", show_controls: false .tree-holder + .nav-block + .tree-controls + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), + rel: 'nofollow', download: '', class: 'btn btn-default download' do + = icon('download') + Download artifacts archive + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + .tree-content-holder %table.table.tree-table %thead diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml new file mode 100644 index 00000000000..d8da83b9a80 --- /dev/null +++ b/app/views/projects/artifacts/file.html.haml @@ -0,0 +1,33 @@ +- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" + += render "projects/builds/header", show_controls: false + +#tree-holder.tree-holder + .nav-block + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + %strong= title + - else + = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + + + %article.file-holder + - blob = @entry.blob + .js-file-title.file-title-flex-parent + = render 'projects/blob/header_content', blob: blob + + .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob + + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) + = open_raw_blob_button(blob) + + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 3f12d64d044..f04df441ccb 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -6,17 +6,14 @@ %li = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do = @project.path - - tree_breadcrumbs(@tree, 6) do |title, path| + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) %li - - if path - - if path.end_with?(@path) - = link_to namespace_project_blob_path(@project.namespace, @project, path) do - %strong - = truncate(title, length: 40) - - else - = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path) + - if path == @path + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do + %strong= title - else - = link_to title, '#' + = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) %ul.blob-commit-info.hidden-xs - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) @@ -25,5 +22,4 @@ #blob-content-holder.blob-content-holder %article.file-holder = render "projects/blob/header", blob: blob - = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 219dc14645b..cd098acda81 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -1,15 +1,6 @@ - blame = local_assigns.fetch(:blame, false) .js-file-title.file-title-flex-parent - .file-header-content - = blob_icon blob.mode, blob.name - - %strong.file-title-name - = blob.name - - = copy_file_path_button(blob.path) - - %small - = number_to_human_size(blob.raw_size) + = render 'projects/blob/header_content', blob: blob .file-actions.hidden-xs = render 'projects/blob/viewer_switcher', blob: blob unless blame diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml new file mode 100644 index 00000000000..98bedae650a --- /dev/null +++ b/app/views/projects/blob/_header_content.html.haml @@ -0,0 +1,10 @@ +.file-header-content + = blob_icon blob.mode, blob.name + + %strong.file-title-name + = blob.name + + = copy_file_path_button(blob.path) + + %small + = number_to_human_size(blob.raw_size) diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml new file mode 100644 index 00000000000..28670e7de97 --- /dev/null +++ b/app/views/projects/blob/viewers/_balsamiq.html.haml @@ -0,0 +1,4 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('balsamiq_viewer') + +.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml index 5a4eaf92b16..bc5c727bf0d 100644 --- a/app/views/projects/boards/components/_board.html.haml +++ b/app/views/projects/boards/components/_board.html.haml @@ -13,8 +13,8 @@ %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button", "@click" => "showNewIssueForm", "v-if" => 'list.type !== "closed"', - "aria-label" => "Add an issue", - "title" => "Add an issue", + "aria-label" => "New issue", + "title" => "New issue", data: { placement: "top", container: "body" } } = icon("plus") - if can?(current_user, :admin_list, @project) diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index e75ce305440..642da679f97 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -1,39 +1,28 @@ -.block.assignee - .title.hide-collapsed - Assignee - - if can?(current_user, :admin_issue, @project) - = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" - .value.hide-collapsed - %span.assign-yourself.no-value{ "v-if" => "!issue.assignee" } - No assignee - - if can?(current_user, :admin_issue, @project) - \- - %a.js-assign-yourself{ href: "#" } - assign yourself - %a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username", - "v-if" => "issue.assignee" } - %img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar", - width: "32", alt: "Avatar" } - %span.author - {{ issue.assignee.name }} - %span.username - = precede "@" do - {{ issue.assignee.username }} +.block.assignee{ ref: "assigneeBlock" } + %template{ "v-if" => "issue.assignees" } + %assignee-title{ ":number-of-assignees" => "issue.assignees.length", + ":loading" => "loadingAssignees", + ":editable" => can?(current_user, :admin_issue, @project) } + %assignees.value{ "root-path" => "#{root_url}", + ":users" => "issue.assignees", + ":editable" => can?(current_user, :admin_issue, @project), + "@assign-self" => "assignSelf" } + - if can?(current_user, :admin_issue, @project) .selectbox.hide-collapsed %input{ type: "hidden", - name: "issue[assignee_id]", - id: "issue_assignee_id", - ":value" => "issue.assignee.id", - "v-if" => "issue.assignee" } + name: "issue[assignee_ids][]", + ":value" => "assignee.id", + "v-if" => "issue.assignees", + "v-for" => "assignee in issue.assignees" } .dropdown - %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true" }, + %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", 'max-select' => 1, dropdown: { header: 'Assignee' } }, ":data-issuable-id" => "issue.id", + ":data-selected" => "assigneeId", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } Select assignee = icon("chevron-down") - .dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author + .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author = dropdown_title("Assign to") = dropdown_filter("Search users") = dropdown_content diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index d3c3e40d518..796ecdfd014 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -1,4 +1,5 @@ - page_title "New Branch" +- default_ref = params[:ref] || @project.default_branch - if @error .alert.alert-danger @@ -16,12 +17,11 @@ .help-block.text-danger.js-branch-name-error .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10 - = hidden_field_tag :ref, params[:ref] || @project.default_branch - = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches", - data: { selected: params[:ref] || @project.default_branch, field_name: 'ref' } }) + .col-sm-10.dropdown.create-from + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-toggle form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = render 'shared/ref_dropdown', dropdown_class: 'wide' .help-block Existing branch name, tag, or commit SHA .form-actions = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index 104db85809c..a0f8f105d9a 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -1,10 +1,13 @@ +- show_controls = local_assigns.fetch(:show_controls, true) - pipeline = @build.pipeline .content-block.build-header.top-area .header-content = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title - Job - %strong.js-build-id ##{@build.id} + %strong + Job + = link_to namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' do + \##{@build.id} in pipeline = link_to pipeline_path(pipeline) do %strong ##{pipeline.id} @@ -15,13 +18,16 @@ = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do %code = @build.ref - - if @build.user - = render "user" + + = render "projects/builds/user" if @build.user + = time_ago_with_tooltip(@build.created_at) - .nav-controls - - if can?(current_user, :create_issue, @project) && @build.failed? - = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post - %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } - = icon('angle-double-left') + + - if show_controls + .nav-controls + - if can?(current_user, :create_issue, @project) && @build.failed? + = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' + - if can?(current_user, :update_build, @build) && @build.retryable? + = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post + %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } + = icon('angle-double-left') diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index c4159ce1a36..43191fae9e6 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -48,7 +48,7 @@ - if @build.merge_request %p.build-detail-row %span.build-light-text Merge Request: - = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) + = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'bold' - if @build.duration %p.build-detail-row %span.build-light-text Duration: diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index f604d6e5fbb..64adb70cb81 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -61,19 +61,20 @@ %span.commit-info.branches %i.fa.fa-spinner.fa-spin - - if @commit.status + - if @commit.last_pipeline + - last_pipeline = @commit.last_pipeline .well-segment.pipeline-info .status-icon-container{ class: "ci-status-icon-#{@commit.status}" } - = link_to namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id) do - = ci_icon_for_status(@commit.status) + = link_to namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) do + = ci_icon_for_status(last_pipeline.status) Pipeline - = link_to "##{@commit.latest_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id), class: "monospace" - = ci_label_for_status(@commit.status) - - if @commit.latest_pipeline.stages.any? + = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id), class: "monospace" + = ci_label_for_status(last_pipeline.status) + - if last_pipeline.stages.any? .mr-widget-pipeline-graph - = render 'shared/mini_pipeline_graph', pipeline: @commit.latest_pipeline, klass: 'js-commit-pipeline-graph' + = render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph' in - = time_interval_in_words @commit.pipelines.total_duration + = time_interval_in_words last_pipeline.duration :javascript $(".commit-info.branches").load("#{branches_namespace_project_commit_path(@project.namespace, @project, @commit.id)}"); diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 0d11da2451a..6051ea2f1ce 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -1,9 +1,11 @@ - @no_container = true +- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : '' +- limited_container_width = fluid_layout || diff_view == :inline ? '' : 'limit-container-width' - page_title "#{@commit.title} (#{@commit.short_id})", "Commits" - page_description @commit.description = render "projects/commits/head" -%div{ class: container_class } +.container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" - if @commit.status = render "ci_menu" @@ -11,7 +13,7 @@ .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - = render "projects/notes/notes_with_form" + = render "shared/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| = render "projects/commit/change", type: type, commit: @commit, title: @commit.title diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 0f080b6acee..1f4c9fac54c 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -9,7 +9,7 @@ = hidden_field_tag :from, params[:from] = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do .dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag' - = render "ref_dropdown" + = render 'shared/ref_dropdown' .compare-ellipsis.inline ... .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown .input-group.inline-input-group @@ -17,7 +17,7 @@ = hidden_field_tag :to, params[:to] = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do .dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag' - = render "ref_dropdown" + = render 'shared/ref_dropdown' = button_tag "Compare", class: "btn btn-create commits-compare-btn" - if @merge_request.present? diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml index c3f95860e92..cdad0bc7231 100644 --- a/app/views/projects/cycle_analytics/_empty_stage.html.haml +++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml @@ -2,6 +2,6 @@ .empty-stage .icon-no-data = custom_icon ('icon_no_data') - %h4 We don't have enough data to show this stage. + %h4 {{ __('We don\'t have enough data to show this stage.') }} %p {{currentStage.emptyStageText}} diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml index 0ffc79b3181..c3eda398234 100644 --- a/app/views/projects/cycle_analytics/_no_access.html.haml +++ b/app/views/projects/cycle_analytics/_no_access.html.haml @@ -2,6 +2,6 @@ .no-access-stage .icon-lock = custom_icon ('icon_lock') - %h4 You need permission. + %h4 {{ __('You need permission.') }} %p - Want to see the data? Please ask administrator for access. + {{ __('Want to see the data? Please ask an administrator for access.') }} diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index dd3fa814716..b158a81471c 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -2,29 +2,30 @@ - page_title "Cycle Analytics" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('locale') = page_specific_javascript_bundle_tag('cycle_analytics') = render "projects/head" #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data - .bordered-box.landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } - = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()") - .row - .col-sm-3.col-xs-12.svg-container - = custom_icon('icon_cycle_analytics_splash') - .col-sm-8.col-xs-12.inner-content - %h4 - Introducing Cycle Analytics - %p - Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project. - - = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' + .landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } + %button.dismiss-button{ type: 'button', 'aria-label': 'Dismiss Cycle Analytics introduction box' } + = icon("times", "@click" => "dismissOverviewDialog()") + .svg-container + = custom_icon('icon_cycle_analytics_splash') + .inner-content + %h4 + {{ __('Introducing Cycle Analytics') }} + %p + {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} + %p + = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } .panel.panel-default .panel-heading - Pipeline Health + {{ __('Pipeline Health') }} .content-block .container-fluid .row @@ -34,15 +35,15 @@ .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } - %span.dropdown-label Last 30 days + %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }} %i.fa.fa-chevron-down %ul.dropdown-menu.dropdown-menu-align-right %li %a{ "href" => "#", "data-value" => "30" } - Last 30 days + {{ n__('Last %d day', 'Last %d days', 30) }} %li %a{ "href" => "#", "data-value" => "90" } - Last 90 days + {{ n__('Last %d day', 'Last %d days', 90) }} .stage-panel-container .panel.panel-default.stage-panel .panel-heading @@ -50,20 +51,20 @@ %ul %li.stage-header %span.stage-name - Stage - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" } + {{ __('ProjectLifecycle|Stage') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header %span.stage-name - Median - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" } + {{ __('Median') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" } %li.event-header %span.stage-name - {{ currentStage ? currentStage.legend : 'Related Issues' }} - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" } + {{ currentStage ? __(currentStage.legend) : __('Related Issues') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } %li.total-time-header %span.stage-name - Total Time - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" } + {{ __('Total Time') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav %ul @@ -75,10 +76,10 @@ %span{ "v-if" => "stage.value" } {{ stage.value }} %span.stage-empty{ "v-else" => true } - Not enough data + {{ __('Not enough data') }} %template{ "v-else" => true } %span.not-available - Not available + {{ __('Not available') }} .section.stage-events %template{ "v-if" => "isLoadingStage" } = icon("spinner spin") diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 4cfbd9add00..74756b58439 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -10,25 +10,4 @@ = render @deploy_keys.form_partial_path .col-lg-9.col-lg-offset-3 %hr - .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys - %h5.prepend-top-0 - Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size}) - - if @deploy_keys.any_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys found. Create one with the form above. - %h5.prepend-top-default - Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size}) - - if @deploy_keys.any_available_project_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys from your projects could be found. Create one with the form above or add existing one below. - - if @deploy_keys.any_available_public_keys_enabled? - %h5.prepend-top-default - Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size}) - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key + #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } } diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 3e426ee9e7d..7439b8a66f7 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -35,6 +35,6 @@ - else = diff_line_content(line.text) -- if line_discussions +- if line_discussions&.any? - discussion_expanded = local_assigns.fetch(:discussion_expanded, line_discussions.any?(&:expanded?)) = render "discussions/diff_discussion", discussions: line_discussions, expanded: discussion_expanded diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 766f119116f..e8f8fbbcf09 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -5,7 +5,7 @@ = page_specific_javascript_bundle_tag('monitoring') = render "projects/pipelines/head" -.prometheus-container{ class: container_class, 'data-has-metrics': "#{@environment.has_metrics?}" } +#js-metrics.prometheus-container{ class: container_class, data: { has_metrics: "#{@environment.has_metrics?}", deployment_endpoint: namespace_project_environment_deployments_path(@project.namespace, @project, @environment, format: :json) } } .top-area .row .col-sm-6 diff --git a/app/views/projects/group_links/_index.html.haml b/app/views/projects/group_links/_index.html.haml index b6116dbec41..debb0214d06 100644 --- a/app/views/projects/group_links/_index.html.haml +++ b/app/views/projects/group_links/_index.html.haml @@ -6,11 +6,9 @@ %p Projects can be stored in only one group at once. However you can share a project with other groups here. .col-lg-9 - %h5.prepend-top-0 - Set a group to share = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do .form-group - = label_tag :link_group_id, "Group", class: "label-light" + = label_tag :link_group_id, "Select a group to share with", class: "label-light" = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, required: true) .form-group = label_tag :link_group_access, "Max access level", class: "label-light" diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 5d4e593e4ef..4dfda54feb5 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -4,4 +4,4 @@ = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' #notes - = render 'projects/notes/notes_with_form' + = render 'shared/notes/notes_with_form' diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 0e3902c066a..c184e0e0022 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -13,9 +13,9 @@ %li CLOSED - - if issue.assignee + - if issue.assignees.any? %li - = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") + = render 'shared/issuable/assignees', project: @project, issue: issue = render 'shared/issuable_meta_data', issuable: issue diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 13e2150f997..6bc6bf76e18 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,9 +1,29 @@ - if can?(current_user, :push_code, @project) - .pull-right - #new-branch.new-branch{ 'data-path' => can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue) } - = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid), - method: :post, class: 'btn btn-new btn-inverted btn-grouped has-tooltip available hide', title: @issue.to_branch_name do - New branch - = link_to '#', class: 'unavailable btn btn-grouped hide', disabled: 'disabled' do - = icon('exclamation-triangle') - New branch unavailable + .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue), create_mr_path: create_merge_request_namespace_project_issue_path(@project.namespace, @project, @issue), create_branch_path: namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } } + .btn-group.unavailable + %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } + = icon('spinner', class: 'fa-spin') + %span.text + Checking branch availability… + .btn-group.available.hide + %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: 'Create a merge request', data: { action: 'create-mr' } } + %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } } + = icon('caret-down') + %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } } + %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a merge request + %span + Creates a branch named after this issue and a merge request. The source branch is '#{@project.default_branch}' by default. + %li.divider.droplab-item-ignore + %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a branch + %span + Creates a branch named after this issue. The source branch is '#{@project.default_branch}' by default. diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 2a871966aa8..9084883eb3e 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -51,16 +51,11 @@ .issue-details.issuable-details .detail-page-description.content-block - .issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title), - "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', } } .issue-title-entrypoint - - if @issue.description.present? - .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } - .wiki - = markdown_field(@issue, :description) - %textarea.hidden.js-task-list-field - = @issue.description + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } @@ -70,8 +65,11 @@ // This element is filled in using JavaScript. .content-block.content-block-small - = render 'new_branch' unless @issue.confidential? - = render 'award_emoji/awards_block', awardable: @issue, inline: true + .row + .col-sm-6 + = render 'award_emoji/awards_block', awardable: @issue, inline: true + .col-sm-6.new-branch-col + = render 'new_branch' unless @issue.confidential? %section.issuable-discussion = render 'projects/issues/discussion' diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 15b5a51c1d0..2e6420db212 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -8,4 +8,4 @@ %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } {{ buttonText }} -#notes= render "projects/notes/notes_with_form" +#notes= render "shared/notes/notes_with_form" diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 881ee9fd596..9e306d4543c 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -6,7 +6,7 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('diff_notes') -.merge-request{ 'data-url' => merge_request_path(@merge_request), 'data-project-path' => project_path(@merge_request.project) } +.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) } = render "projects/merge_requests/show/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml index 547be78992e..11b0c55be0b 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -35,7 +35,7 @@ %span.dropdown.inline.mr-version-compare-dropdown %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} } %span - - if @start_sha + - if @start_version version #{version_index(@start_version)} - else #{@merge_request.target_branch} @@ -59,7 +59,7 @@ %small = time_ago_with_tooltip(merge_request_diff.created_at) %li - = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_sha) do + = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do %strong #{@merge_request.target_branch} (base) .monospace= short_sha(@merge_request_diff.base_commit_sha) @@ -75,13 +75,15 @@ = succeed '.' do %code= @merge_request.target_branch - - if @diff_notes_disabled + - if @start_version || !@merge_request_diff.latest? .comments-disabled-notif.content-block = icon('info-circle') - - if @start_sha - Comments are disabled because you're comparing two versions of this merge request. + Not all comments are displayed because you're + - if @start_version + comparing two versions - else - Discussions on this version of the merge request are displayed but comment creation is disabled. + viewing an old version + of this merge request. .pull-right = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 0f4a8508751..9a95b2a82ff 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -9,9 +9,9 @@ .form-group.milestone-description = f.label :description, "Description", class: "control-label" .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' - = render 'projects/notes/hints' + = render 'shared/notes/hints' .clearfix .error-alert = render "shared/milestones/form_dates", f: f diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 718b52dd82e..d70ec8a6062 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -31,14 +31,14 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/projects/notes/_edit.html.haml b/app/views/projects/notes/_edit.html.haml deleted file mode 100644 index f1e251d65b7..00000000000 --- a/app/views/projects/notes/_edit.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -.original-note-content.hidden{ data: { post_url: namespace_project_note_path(@project.namespace, @project, note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } - #{note.note} -%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: namespace_project_note_path(@project.namespace, @project, note) } }= note.note diff --git a/app/views/projects/pages/_disabled.html.haml b/app/views/projects/pages/_disabled.html.haml deleted file mode 100644 index ad51fbc6cab..00000000000 --- a/app/views/projects/pages/_disabled.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.panel.panel-default - .nothing-here-block - GitLab Pages are disabled. - Ask your system's administrator to enable it. diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 259d5bd63d6..b22a54d75c8 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -16,13 +16,10 @@ %hr.clearfix -- if Gitlab.config.pages.enabled - = render 'access' - = render 'use' - - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https - = render 'list' - - else - = render 'no_domains' - = render 'destroy' += render 'access' += render 'use' +- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render 'list' - else - = render 'disabled' + = render 'no_domains' += render 'destroy' diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index bc57f7f1c46..b0dac9de1c6 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -4,13 +4,13 @@ .nav-links.sub-nav.scrolling-tabs %ul{ class: (container_class) } - if project_nav_tab? :pipelines - = nav_link(path: 'pipelines#index', controller: :pipelines) do + = nav_link(path: ['pipelines#index', 'pipelines#show']) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines - if project_nav_tab? :builds - = nav_link(controller: :builds) do + = nav_link(controller: [:builds, :artifacts]) do = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do %span Jobs diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index d7cefb8613e..1aa48bf9813 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,3 +1,5 @@ +- failed_builds = @pipeline.statuses.latest.failed + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom %li.js-pipeline-tab-link @@ -7,8 +9,11 @@ = link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do Jobs %span.badge.js-builds-counter= pipeline.statuses.count - - + - if failed_builds.present? + %li.js-failures-tab-link + = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do + Failed Jobs + %span.badge.js-failures-counter= failed_builds.count .tab-content #js-tab-pipeline.tab-pane @@ -39,3 +44,13 @@ %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage + - if failed_builds.present? + #js-tab-failures.build-failures.tab-pane + - failed_builds.each_with_index do |build, index| + .build-state + %span.ci-status-icon-failed= custom_icon('icon_status_failed') + %span.stage + = build.stage.titleize + %span.build-name + = link_to build.name, pipeline_build_url(pipeline, build) + %pre.build-log= build_summary(build, skip: index >= 10) diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml index ab0771b5751..d080b6c83d4 100644 --- a/app/views/projects/project_members/_index.html.haml +++ b/app/views/projects/project_members/_index.html.haml @@ -6,13 +6,19 @@ %p Add a new member to %strong= @project.name + - else + %p + Members can be added by project + %i Masters + or + %i Owners .col-lg-9 .light.prepend-top-default - if can?(current_user, :admin_project_member, @project) = render "projects/project_members/new_project_member" = render 'shared/members/requests', membership_source: @project, requesters: @requesters - .append-bottom-default.clearfix + .clearfix %h5.member.existing-title Existing members and groups - if @group_links.any? diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 81d57c77edf..7b1a26043e1 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -1,9 +1,11 @@ .panel.panel-default - .panel-heading - Members with access to - %strong= @project.name + .panel-heading.flex-project-members-panel + %span.flex-project-title + Members of + %strong + #{@project.name} %span.badge= @project_members.total_count - = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do + = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index b8e885b4d9a..99bc2516366 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -25,7 +25,7 @@ .merge_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-merge wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) .form-group %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } @@ -34,7 +34,7 @@ .push_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-push wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) .panel-footer diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml index d6044aacaec..c61b2951e1e 100644 --- a/app/views/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -1,10 +1,10 @@ %td = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) %td = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index 6e187b54a59..af9a080f0a2 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -9,7 +9,7 @@ .form-group = f.label :name, class: 'col-md-2 text-right' do Tag: - .col-md-10 + .col-md-10.protected-tags-dropdown = render partial: "projects/protected_tags/dropdown", locals: { f: f } .help-block = link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags') diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml index 74851519077..c50515cfe06 100644 --- a/app/views/projects/protected_tags/_dropdown.html.haml +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -2,7 +2,7 @@ = dropdown_tag('Select tag or create wildcard', options: { toggle_class: 'js-protected-tag-select js-filter-submit wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected tag", + filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header", placeholder: "Search protected tag", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml index 62823bee46e..cc80bd04dd0 100644 --- a/app/views/projects/protected_tags/_update_protected_tag.haml +++ b/app/views/projects/protected_tags/_update_protected_tag.haml @@ -1,5 +1,5 @@ %td = hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level = dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable js-allowed-to-create-container', + options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container', data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }}) diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml index 79d8d721aa9..93ee9382a6e 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/releases/edit.html.haml @@ -11,9 +11,9 @@ = form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f| - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." - = render 'projects/notes/hints' + = render 'shared/notes/hints' .error-alert .prepend-top-default = f.submit 'Save changes', class: 'btn btn-save' diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml index 5a5ade03624..8c7f9e0191e 100644 --- a/app/views/projects/settings/_head.html.haml +++ b/app/views/projects/settings/_head.html.haml @@ -27,7 +27,8 @@ = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do %span CI/CD Pipelines - = nav_link(controller: :pages) do - = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do - %span - Pages + - if Gitlab.config.pages.enabled + = nav_link(controller: :pages) do + = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do + %span + Pages diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 5402320cb66..4e59033c4a3 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,6 +1,10 @@ - page_title "Repository" = render "projects/settings/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('deploy_keys') + = render @deploy_keys = render "projects/protected_branches/index" = render "projects/protected_tags/index" diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 7a175f63eeb..aab1c043e66 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -9,4 +9,4 @@ .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "projects/notes/notes_with_form" + #notes= render "shared/notes/notes_with_form" diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 7f9a44e565f..56656ea3d86 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -1,4 +1,5 @@ - @no_container = true +- @sort ||= sort_value_recently_updated - page_title "Tags" = render "projects/commits/head" @@ -14,16 +15,14 @@ .dropdown %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light - = projects_sort_options_hash[@sort] + = tags_sort_options_hash[@sort] = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to filter_tags_path(sort: sort_value_name) do - = sort_title_name - = link_to filter_tags_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to filter_tags_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - tags_sort_options_hash.each do |value, title| + %li + = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value) - if can?(current_user, :push_code, @project) = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do New tag diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 160d4c7a223..7c607d2956b 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -28,9 +28,9 @@ .form-group = label_tag :release_description, 'Release notes', class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." - = render 'projects/notes/hints' + = render 'shared/notes/hints' .help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page. .form-actions = button_tag 'Create tag', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index e7b3fe3ffda..396d1ecd77b 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -9,12 +9,9 @@ %li = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do = @project.path - - tree_breadcrumbs(tree, 6) do |title, path| + - path_breadcrumbs do |title, path| %li - - if path - = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path) - - else - = link_to title, '#' + = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) - if current_user %li diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 0d2cd4a7476..6cb7c1e9c4d 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -12,9 +12,9 @@ .form-group = f.label :content, class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: namespace_project_wiki_preview_markdown_path(@project.namespace, @project, @page.slug) } do = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...' - = render 'projects/notes/hints' + = render 'shared/notes/hints' .clearfix .error-alert diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index b0778653d4e..07970ad9cba 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -11,8 +11,8 @@ = icon('caret-down') %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container - .arrow-up - .js-builds-dropdown-list.scrollable-menu + %li.js-builds-dropdown-list.scrollable-menu - .js-builds-dropdown-loading.builds-dropdown-loading.hidden - %span.fa.fa-spinner.fa-spin + %li.js-builds-dropdown-loading.hidden + .text-center + %i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' } diff --git a/app/views/projects/compare/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml index 05fb37cdc0f..96f68c80c48 100644 --- a/app/views/projects/compare/_ref_dropdown.html.haml +++ b/app/views/shared/_ref_dropdown.html.haml @@ -1,4 +1,6 @@ -.dropdown-menu.dropdown-menu-selectable +- dropdown_class = local_assigns.fetch(:dropdown_class, '') + +.dropdown-menu.dropdown-menu-selectable{ class: dropdown_class } = dropdown_title "Select Git revision" = dropdown_filter "Filter by Git revision" = dropdown_content diff --git a/app/views/shared/errors/_graphic_422.svg b/app/views/shared/errors/_graphic_422.svg new file mode 100644 index 00000000000..87128ecd69d --- /dev/null +++ b/app/views/shared/errors/_graphic_422.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 246" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="178" height="136" rx="10"/><mask id="1" width="178" height="136" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#e5e5e5" fill-rule="nonzero"><path d="m109.88 37.634c5.587-3.567 12.225-5.634 19.345-5.634 7.445 0 14.363 2.26 20.1 6.132l21.435-37.13c.554-.959 1.771-1.292 2.734-.736.957.552 1.284 1.777.73 2.736l-21.496 37.23c-.065.112-.138.215-.219.309 3.686 3.13 6.733 6.988 8.919 11.353l-3.393.002c-5.775-10.322-16.705-16.901-28.814-16.901-12.12 0-23.06 6.594-28.833 16.935l-3.393.002c2.32-4.646 5.616-8.72 9.618-11.954l-21.349-36.977c-.554-.959-.227-2.184.73-2.736.963-.556 2.181-.223 2.734.736l21.15 36.629"/><path d="m3 70v134c0 9.389 7.611 17 16.997 17h220.01c9.389 0 16.997-7.611 16.997-17v-134c0-9.389-7.611-17-16.997-17h-220.01c-9.389 0-16.997 7.611-16.997 17m-3 0c0-11.05 8.95-20 19.997-20h220.01c11.04 0 19.997 8.958 19.997 20v134c0 11.05-8.95 20-19.997 20h-220.01c-11.04 0-19.997-8.958-19.997-20v-134"/></g><ellipse cx="129" cy="241.5" fill="#f9f9f9" rx="89" ry="4.5"/><g fill-rule="nonzero" transform="translate(210 70)"><path fill="#eaeaea" d="m16 29c7.18 0 13-5.82 13-13 0-7.18-5.82-13-13-13-7.18 0-13 5.82-13 13 0 7.18 5.82 13 13 13m0 3c-8.837 0-16-7.163-16-16 0-8.837 7.163-16 16-16 8.837 0 16 7.163 16 16 0 8.837-7.163 16-16 16" id="2"/><path fill="#6b4fbb" d="m16 21c2.761 0 5-2.239 5-5 0-2.761-2.239-5-5-5-2.761 0-5 2.239-5 5 0 2.761 2.239 5 5 5m0 3c-4.418 0-8-3.582-8-8 0-4.418 3.582-8 8-8 4.418 0 8 3.582 8 8 0 4.418-3.582 8-8 8" id="3"/></g><g fill-rule="nonzero" transform="translate(210 109)"><use xlink:href="#2"/><use xlink:href="#3"/></g><g transform="translate(210 147)"><path fill="#e5e5e5" fill-rule="nonzero" d="m3 5.992v45.02c0 1.647 1.346 2.992 3 2.992h20c1.657 0 3-1.341 3-2.992v-45.02c0-1.647-1.346-2.992-3-2.992h-20c-1.657 0-3 1.341-3 2.992m-3 0c0-3.309 2.687-5.992 6-5.992h20c3.314 0 6 2.692 6 5.992v45.02c0 3.309-2.687 5.992-6 5.992h-20c-3.314 0-6-2.692-6-5.992v-45.02"/><rect width="16" height="4" x="8" y="27" fill="#fdb692" rx="2"/><rect width="16" height="4" x="8" y="19" fill="#fc9867" rx="2"/><rect width="16" height="4" x="8" y="11" fill="#fc6d26" rx="2"/><rect width="16" height="4" x="8" y="35" fill="#fed3bd" rx="2"/><rect width="16" height="4" x="8" y="43" fill="#fef0e9" rx="2"/></g><g transform="translate(16 69)"><use fill="#6b4fbb" fill-opacity=".1" stroke="#e5e5e5" stroke-width="6" mask="url(#1)" xlink:href="#0"/><g class="tv-screen" fill="#fff"><path opacity=".4" mix-blend-mode="overlay" d="m3 17h172v16h-172z"/><path opacity=".6" mix-blend-mode="overlay" d="m3 70h172v24h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 107h172v16h-172z"/><path opacity=".4" mix-blend-mode="overlay" d="m3 40h172v8h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 55h172v8h-172z"/></g></g><path class="text-422" d="m.693 19h5.808c.277 0 .498-.224.498-.5 0-.268-.223-.5-.498-.5h-5.808v-2.094l3.777-5.906h3.916l-4.124 6.454h6.259v-6.454h.978c.273 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-.978v-2h4.698v6h-2.721c-.277 0-.498.224-.498.5 0 .268.223.5.498.5h2.721v2.454h2.723v4.2h-2.723v5.346h-4.698v-5.346h-9.828v-1.654m4.417-10l1.279-2h3.914l-1.278 2h-3.916m1.919-3l1.279-2h4.192c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.552l1.142-1.786h5.13v4.786h-8.191m31.09 19v1h-15.738v-2h5.118c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-5.118v-1.184l2.656-2.822c.682-.725 1.306-1.39 1.872-1.994h5.428c-.389.394-.808.815-1.256 1.264-1.428 1.428-2.562 2.568-3.403 3.42h10.442v2.316h-4.614c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.614m-6.674-13c.493-.631.87-1.208 1.129-1.73.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.589c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.589v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-2.782c-.27 0-.5.224-.5.5 0 .268.224.5.5.5h3.602c.654 1.01.981 2.209.981 3.605 0 .974-.163 1.887-.49 2.739-.326.852-.888 1.798-1.685 2.839-.397.509-1.261 1.448-2.594 2.816h-5.474c1.34-1.436 2.261-2.436 2.763-3h4.396c.271 0 .499-.224.499-.5 0-.268-.223-.5-.499-.5h-3.557m28.14 12v2h-15.738v-4.184l2.651-2.816h5.313c-1.087 1.089-1.976 1.983-2.668 2.684h10.442v1.316h-4.083c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.083m-2.069-11c-.045.061-.092.122-.139.184-.567.727-2.089 2.333-4.568 4.816h-5.372c2.601-2.77 4.204-4.503 4.81-5.198.83-.952 1.428-1.796 1.793-2.532.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.117c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-3.117v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-1.248c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.069c.654 1.01.981 2.209.981 3.605 0 .844-.123 1.642-.368 2.395h-2.683c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.272c-.159.321-.347.655-.566 1h-3.706c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h3.01" transform="translate(75 124)" fill="#5c5c5c"/></g></svg> diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml new file mode 100644 index 00000000000..36bbb1148d4 --- /dev/null +++ b/app/views/shared/issuable/_assignees.html.haml @@ -0,0 +1,15 @@ +- max_render = 3 +- max = [max_render, issue.assignees.length].min + +- issue.assignees.each_with_index do |assignee, index| + - if index < max + = link_to_member(@project, assignee, name: false, title: "Assigned to :name") + +- if issue.assignees.length > max_render + - counter = issue.assignees.length - max_render + + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } } + - if counter < 99 + = "+#{counter}" + - else + 99+ diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 17107f55a2d..7748351b333 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -17,7 +17,7 @@ = render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) -= render 'shared/issuable/form/description', issuable: issuable, form: form += render 'shared/issuable/form/description', issuable: issuable, form: form, project: project - if issuable.respond_to?(:confidential) .form-group diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml index 171da899937..db407363a09 100644 --- a/app/views/shared/issuable/_participants.html.haml +++ b/app/views/shared/issuable/_participants.html.haml @@ -12,9 +12,9 @@ - participants.each do |participant| .participants-author.js-participants-author = link_to_member(@project, participant, name: false, size: 24) - - if participants_extra > 0 - .participants-more - %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more + - if participants_extra > 0 + .hide-collapsed.participants-more + %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } + + #{participants_extra} more :javascript IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row}; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f1350169bbe..f7b87171573 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -117,21 +117,26 @@ .issues_bulk_update.hide = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do .filter-item.inline - = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do + = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do %ul %li %a{ href: "#", data: { id: "reopen" } } Open %li %a{ href: "#", data: { id: "close" } } Closed .filter-item.inline + - if type == :issues + - field_name = "update[assignee_ids][]" + - else + - field_name = "update[assignee_id]" + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) .filter-item.inline - = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } }) .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" } .filter-item.inline - = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do + = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do %ul %li %a{ href: "#", data: { id: "subscribe" } } Subscribe diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 2e0d6a129fb..44e624c15a7 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,10 +1,10 @@ - todo = issuable_todo(issuable) - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('issuable') + = page_specific_javascript_bundle_tag('sidebar') %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } - .issuable-sidebar + .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - if current_user @@ -20,36 +20,55 @@ .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true .block.assignee - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') - .title.hide-collapsed - Assignee - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.hide-collapsed - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - = issuable.assignee.to_reference - - else - %span.assign-yourself.no-value - No assignee - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - assign yourself + - if issuable.instance_of?(Issue) + #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } + - else + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do + - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = issuable.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself .selectbox.hide-collapsed - = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' - = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }) + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil + - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + + - if issuable.instance_of?(Issue) + - if issuable.assignees.length == 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil + - title = 'Select assignee' + - options[:toggle_class] += ' js-multiselect js-save-user-data' + - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" + - options[:data][:multi_select] = true + - options[:data]['dropdown-title'] = title + - options[:data]['dropdown-header'] = 'Assignee' + - options[:data]['max-select'] = 1 + - else + - title = 'Select assignee' + + = dropdown_tag(title, options: options) .block.milestone .sidebar-collapsed-icon = icon('clock-o', 'aria-hidden': 'true') @@ -75,11 +94,10 @@ = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block - %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') } - // Fallback while content is loading - .title.hide-collapsed - Time tracking - = icon('spinner spin', 'aria-hidden': 'true') + // Fallback while content is loading + .title.hide-collapsed + Time tracking + = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date .sidebar-collapsed-icon @@ -136,7 +154,7 @@ - selected_labels.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project) } } + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project) } } %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') @@ -169,8 +187,13 @@ = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") :javascript - gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}'); - new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}"); + gl.sidebarOptions = { + endpoint: "#{issuable_json_path(issuable)}", + editable: #{can_edit_issuable ? true : false}, + currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)}, + rootPath: "#{root_path}" + }; + new MilestoneSelect('{"full_path":"#{@project.full_path}"}'); new LabelsSelect(); new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}'); diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 2793e7bcff4..f57b4d899ce 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -10,12 +10,16 @@ = form.label :source_branch, class: 'control-label' .col-sm-10 .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 span2', disabled: true }) + = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2', disabled: true }) .form-group = form.label :target_branch, class: 'control-label' - .col-sm-10 + .col-sm-10.target-branch-select-dropdown-container .issuable-form-select-holder - = form.select(:target_branch, issuable.target_branches, { include_blank: true }, { class: 'target_branch select2 span2', disabled: issuable.new_record?, data: { placeholder: "Select branch" }}) + = form.select(:target_branch, issuable.target_branches, + { include_blank: true }, + { class: 'target_branch js-target-branch-select', + disabled: issuable.new_record?, + data: { placeholder: "Select branch" }}) - if issuable.new_record? = link_to 'Change branches', mr_change_branches_path(issuable) diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/issuable/form/_description.html.haml index dbace9ce401..7ef0ae96be2 100644 --- a/app/views/shared/issuable/form/_description.html.haml +++ b/app/views/shared/issuable/form/_description.html.haml @@ -1,15 +1,22 @@ +- project = local_assigns.fetch(:project) - issuable = local_assigns.fetch(:issuable) - form = local_assigns.fetch(:form) +- supports_slash_commands = issuable.new_record? + +- if supports_slash_commands + - preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name) +- else + - preview_url = preview_markdown_path(project) .form-group.detail-page-description = form.label :description, 'Description', class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea', placeholder: "Write a comment or drag your files here...", - supports_slash_commands: !issuable.persisted? - = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted? + supports_slash_commands: supports_slash_commands + = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands .clearfix .error-alert diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml new file mode 100644 index 00000000000..c33474ac3b4 --- /dev/null +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -0,0 +1,30 @@ +- issue = issuable +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) } + - if issue.assignees.any? + - issue.assignees.each do |assignee| + = link_to_member(@project, assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issue.assignees.any? + - issue.assignees.each do |assignee| + = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do + %span.username + = assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_ids', value: issuable.assignee_ids, id: 'issue_assignee_ids' + = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }) diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml new file mode 100644 index 00000000000..18011d528a0 --- /dev/null +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -0,0 +1,31 @@ +- merge_request = issuable +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) } + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do + - unless merge_request.can_be_merged_by?(merge_request.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = merge_request.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id' + = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } }) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 9dbfedb84f1..9281a515744 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -10,13 +10,27 @@ .row %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } .form-group.issue-assignee - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = form.hidden_field :assignee_id - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + - if issuable.is_a?(Issue) + = form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder.selectbox + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } + + - if issuable.assignees.length === 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } + + = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false)) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + - else + = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = form.hidden_field :assignee_id + + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" .form-group.issue-milestone = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index 10050adfda5..92f6e7428ae 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -1,5 +1,5 @@ - if requesters.any? - .panel.panel-default + .panel.panel-default.prepend-top-default .panel-heading Users requesting access to %strong= membership_source.name diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 5247d6a51e6..22547a30cdf 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -1,7 +1,7 @@ -# @project is present when viewing Project's milestone - project = @project || issuable.project - namespace = @project_namespace || project.namespace.becomes(Namespace) -- assignee = issuable.assignee +- assignees = issuable.assignees - issuable_type = issuable.class.table_name - base_url_args = [namespace, project] - issuable_type_args = base_url_args + [issuable_type] @@ -26,7 +26,7 @@ - render_colored_label(label) %span.assignee-icon - - if assignee - = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }), + - assignees.each do |assignee| + = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }), class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do - - image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '') + - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '') diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index 33f93dccd3c..a26b3b8009e 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -2,7 +2,7 @@ - labels.each do |label| - options = { milestone_title: @milestone.title, label_name: label.title } - %li + %li.is-not-draggable %span.label-row %span.label-name = link_to milestones_label_path(options) do @@ -10,10 +10,8 @@ %span.prepend-description-left = markdown_field(label, :description) - .pull-info-right - %span.append-right-20 - = link_to milestones_label_path(options.merge(state: 'opened')) do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' - %span.append-right-20 - = link_to milestones_label_path(options.merge(state: 'closed')) do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' + .pull-right.hidden-xs.hidden-sm.hidden-md + = link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do + - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' + = link_to milestones_label_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do + - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml new file mode 100644 index 00000000000..68458c2d0aa --- /dev/null +++ b/app/views/shared/milestones/_tab_loading.html.haml @@ -0,0 +1,2 @@ +.text-center.prepend-top-default + = icon('spin spinner 2x', 'aria-hidden': 'true', 'aria-label': 'Loading tab content') diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 9a4502873ef..6a6d817b344 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,27 +1,27 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= icon('angle-left') .fade-right= icon('angle-right') - %ul.nav-links.scrolling-tabs + %ul.nav-links.scrolling-tabs.js-milestone-tabs - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) %li.active = link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do Issues %span.badge= milestone.issues_visible_to_user(current_user).size %li - = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do + = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do Merge Requests %span.badge= milestone.merge_requests.size - else %li.active - = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do + = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do Merge Requests %span.badge= milestone.merge_requests.size %li - = link_to '#tab-participants', 'data-toggle' => 'tab' do + = link_to '#tab-participants', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do Participants %span.badge= milestone.participants.count %li - = link_to '#tab-labels', 'data-toggle' => 'tab' do + = link_to '#tab-labels', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do Labels %span.badge= milestone.labels.count @@ -30,14 +30,18 @@ .tab-content.milestone-content - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) - .tab-pane.active#tab-issues + .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } = render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user).include_associations, show_project_name: show_project_name, show_full_project_name: show_full_project_name - .tab-pane#tab-merge-requests - = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane#tab-merge-requests{ data: { sort_endpoint: (sort_merge_requests_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } + -# loaded async + = render "shared/milestones/tab_loading" - else - .tab-pane.active#tab-merge-requests - = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane.active#tab-merge-requests{ data: { sort_endpoint: (sort_merge_requests_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-participants - = render 'shared/milestones/participants_tab', users: milestone.participants + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-labels - = render 'shared/milestones/labels_tab', labels: milestone.labels + -# loaded async + = render "shared/milestones/tab_loading" diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index 29cf5825292..29cf5825292 100644 --- a/app/views/projects/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml new file mode 100644 index 00000000000..4a020865828 --- /dev/null +++ b/app/views/shared/notes/_edit.html.haml @@ -0,0 +1,3 @@ +.original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } + #{note.note} +%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: note_url(note) } }= note.note diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index 8b4e5928e0d..8923e5602a4 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -2,13 +2,13 @@ = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do = hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_type, '', class: 'js-form-target-type' - = render layout: 'projects/md_preview', locals: { preview_class: 'md-preview', referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..." - = render 'projects/notes/hints' + = render 'shared/notes/hints' .note-form-actions.clearfix - .settings-message.note-edit-warning.js-edit-warning + .settings-message.note-edit-warning.js-finish-edit-warning Finish editing this message first! - = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-button' + = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-save-button' %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' } Cancel diff --git a/app/views/projects/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 0d835a9e949..eaf50bc2115 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -1,6 +1,10 @@ - supports_slash_commands = note_supports_slash_commands?(@note) +- if supports_slash_commands + - preview_url = preview_markdown_path(@project, slash_commands_target_type: @note.noteable_type, slash_commands_target_id: @note.noteable_id) +- else + - preview_url = preview_markdown_path(@project) -= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| += form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha) @@ -18,17 +22,17 @@ -# DiffNote = f.hidden_field :position - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here...", supports_slash_commands: supports_slash_commands - = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands + = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands .error-alert .note-form-actions.clearfix - = render partial: 'projects/notes/comment_button' + = render partial: 'shared/notes/comment_button' = yield(:note_actions) diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 81d97eabe65..81d97eabe65 100644 --- a/app/views/projects/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 731270d4127..5c1156b06fb 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -2,7 +2,11 @@ - return if note.cross_reference_not_visible_for?(current_user) - note_editable = note_editable?(note) -%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} } +%li.timeline-entry{ id: dom_id(note), + class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], + data: { author_id: note.author.id, + editable: note_editable, + note_id: note.id } } .timeline-entry-inner .timeline-icon - if note.system @@ -36,12 +40,9 @@ .note-body{ class: note_editable ? 'js-task-list-container' : '' } .note-text.md = note.redacted_note_html - = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') - if note_editable - - if note.for_personal_snippet? - = render 'snippets/notes/edit', note: note - - else - = render 'projects/notes/edit', note: note + = render 'shared/notes/edit', note: note .note-awards = render 'award_emoji/awards_block', awardable: note, inline: false - if note.system diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 555228623cc..9930cbd96d7 100644 --- a/app/views/projects/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,18 +1,18 @@ %ul#notes-list.notes.main-notes-list.timeline = render "shared/notes/notes" -= render 'projects/notes/edit_form' += render 'shared/notes/edit_form', project: @project %ul.notes.notes-form.timeline %li.timeline-entry .flash-container.timeline-content - - if can? current_user, :create_note, @project + - if can_create_note? .timeline-icon.hidden-xs.hidden-sm %a.author_link{ href: user_path(current_user) } = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form - = render "projects/notes/form", view: diff_view + = render "shared/notes/form", view: diff_view - elsif !current_user .disabled-comment.text-center .disabled-comment-text.inline @@ -23,4 +23,4 @@ to post a comment :javascript - var notes = new Notes("#{namespace_project_noteable_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}") + var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}") diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 9bcb4544b97..11f0fa7c49f 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -1,15 +1,6 @@ - blob = @snippet.blob .js-file-title.file-title-flex-parent - .file-header-content - = blob_icon blob.mode, blob.path - - %strong.file-title-name - = blob.path - - = copy_file_path_button(blob.path) - - %small - = number_to_human_size(blob.raw_size) + = render 'projects/blob/header_content', blob: blob .file-actions.hidden-xs = render 'projects/blob/viewer_switcher', blob: blob diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index d084f5e9684..501c09d71d5 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -21,4 +21,4 @@ = markdown_field(@snippet, :title) - if @snippet.updated_at != @snippet.created_at - = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago') + = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index dace11e5474..679a5e934da 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -1,13 +1,13 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/snippets/notes/_notes.html.haml b/app/views/snippets/notes/_notes.html.haml deleted file mode 100644 index f07d6b8c126..00000000000 --- a/app/views/snippets/notes/_notes.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -%ul#notes-list.notes.main-notes-list.timeline - = render "projects/notes/notes" diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 98287cba5b4..51dbbc32cc9 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -2,11 +2,11 @@ = render 'shared/snippets/header' -%article.file-holder.snippet-file-content - = render 'shared/snippets/blob' +.personal-snippets + %article.file-holder.snippet-file-content + = render 'shared/snippets/blob' -.row-content-block.top-block.content-component-block - = render 'award_emoji/awards_block', awardable: @snippet, inline: true + .row-content-block.top-block.content-component-block + = render 'award_emoji/awards_block', awardable: @snippet, inline: true -%ul#notes-list.notes.main-notes-list.timeline - #notes= render 'shared/notes/notes' + #notes= render "shared/notes/notes_with_form" diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml new file mode 100644 index 00000000000..0545cab538c --- /dev/null +++ b/app/views/users/_deletion_guidance.html.haml @@ -0,0 +1,10 @@ +- user = local_assigns.fetch(:user) + +%ul + %li + %p + Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the + = link_to 'user account deletion documentation.', help_page_path("user/profile/account/delete_account", anchor: "associated-records") + - personal_projects_count = user.personal_projects.count + - unless personal_projects_count.zero? + %li #{pluralize(personal_projects_count, 'personal project')} will be removed and cannot be restored diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 015a41b6e82..127d8dfbb61 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -2,27 +2,24 @@ class PostReceive include Sidekiq::Worker include DedicatedSidekiqQueue - def perform(repo_path, identifier, changes) - repo_relative_path = Gitlab::RepoPath.strip_storage_path(repo_path) + def perform(project_identifier, identifier, changes) + project, is_wiki = parse_project_identifier(project_identifier) + + if project.nil? + log("Triggered hook for non-existing project with identifier \"#{project_identifier}\"") + return false + end changes = Base64.decode64(changes) unless changes.include?(' ') # Use Sidekiq.logger so arguments can be correlated with execution # time and thread ID's. Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] - post_received = Gitlab::GitPostReceive.new(repo_relative_path, identifier, changes) - - if post_received.project.nil? - log("Triggered hook for non-existing project with full path \"#{repo_relative_path}\"") - return false - end + post_received = Gitlab::GitPostReceive.new(project, identifier, changes) - if post_received.wiki? + if is_wiki # Nothing defined here yet. - elsif post_received.regular_project? - process_project_changes(post_received) else - log("Triggered hook for unidentifiable repository type with full path \"#{repo_relative_path}\"") - false + process_project_changes(post_received) end end @@ -47,6 +44,21 @@ class PostReceive private + # To maintain backwards compatibility, we accept both gl_repository or + # repository paths as project identifiers. Our plan is to migrate to + # gl_repository only with the following plan: + # 9.2: Handle both possible values. Keep Gitlab-Shell sending only repo paths + # 9.3 (or patch release): Make GitLab Shell pass gl_repository if present + # 9.4 (or patch release): Make GitLab Shell always pass gl_repository + # 9.5 (or patch release): Handle only gl_repository as project identifier on this method + def parse_project_identifier(project_identifier) + if project_identifier.start_with?('/') + Gitlab::RepoPath.parse(project_identifier) + else + Gitlab::GlRepository.parse(project_identifier) + end + end + def log(message) Gitlab::GitLogger.error("POST-RECEIVE: #{message}") end diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb new file mode 100644 index 00000000000..5ce0e0405d0 --- /dev/null +++ b/app/workers/propagate_service_template_worker.rb @@ -0,0 +1,21 @@ +# Worker for updating any project specific caches. +class PropagateServiceTemplateWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + LEASE_TIMEOUT = 4.hours.to_i + + def perform(template_id) + return unless try_obtain_lease_for(template_id) + + Projects::PropagateServiceTemplate.propagate(Service.find_by(id: template_id)) + end + + private + + def try_obtain_lease_for(template_id) + Gitlab::ExclusiveLease. + new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT). + try_obtain + end +end diff --git a/changelogs/unreleased/12910-personal-snippets-notes.yml b/changelogs/unreleased/12910-personal-snippets-notes.yml new file mode 100644 index 00000000000..7f1576c3513 --- /dev/null +++ b/changelogs/unreleased/12910-personal-snippets-notes.yml @@ -0,0 +1,4 @@ +--- +title: Support comments for personal snippets +merge_request: +author: diff --git a/changelogs/unreleased/12910-uploader-pers-snippet.yml b/changelogs/unreleased/12910-uploader-pers-snippet.yml new file mode 100644 index 00000000000..1c163632fc6 --- /dev/null +++ b/changelogs/unreleased/12910-uploader-pers-snippet.yml @@ -0,0 +1,4 @@ +--- +title: Support uploaders for personal snippets comments +merge_request: +author: diff --git a/changelogs/unreleased/17361-redirect-renamed-paths.yml b/changelogs/unreleased/17361-redirect-renamed-paths.yml new file mode 100644 index 00000000000..7a33c9fb3ec --- /dev/null +++ b/changelogs/unreleased/17361-redirect-renamed-paths.yml @@ -0,0 +1,4 @@ +--- +title: Redirect old links after renaming a user/group/project. +merge_request: 10370 +author: diff --git a/changelogs/unreleased/2247-emails-forwarded-to-service-desk-email-don-t-come.yml b/changelogs/unreleased/2247-emails-forwarded-to-service-desk-email-don-t-come.yml new file mode 100644 index 00000000000..f062143960e --- /dev/null +++ b/changelogs/unreleased/2247-emails-forwarded-to-service-desk-email-don-t-come.yml @@ -0,0 +1,4 @@ +--- +title: Handle incoming emails from aliases correctly +merge_request: +author: diff --git a/changelogs/unreleased/23751-add-contribution-graph-key-tooltips.yml b/changelogs/unreleased/23751-add-contribution-graph-key-tooltips.yml new file mode 100644 index 00000000000..7c4c6fb46a0 --- /dev/null +++ b/changelogs/unreleased/23751-add-contribution-graph-key-tooltips.yml @@ -0,0 +1,4 @@ +--- +title: Add tooltips to user contribution graph key +merge_request: 11138 +author: diff --git a/changelogs/unreleased/24883-build-failure-summary-page.yml b/changelogs/unreleased/24883-build-failure-summary-page.yml new file mode 100644 index 00000000000..214cd3e2bc7 --- /dev/null +++ b/changelogs/unreleased/24883-build-failure-summary-page.yml @@ -0,0 +1,4 @@ +--- +title: Added build failures summary page for pipelines +merge_request: 10719 +author: diff --git a/changelogs/unreleased/26883-members-page-layout-looks-broken.yml b/changelogs/unreleased/26883-members-page-layout-looks-broken.yml new file mode 100644 index 00000000000..e0e3a529c3e --- /dev/null +++ b/changelogs/unreleased/26883-members-page-layout-looks-broken.yml @@ -0,0 +1,4 @@ +--- +title: Improved UX on project members settings view +merge_request: +author: diff --git a/changelogs/unreleased/27614-instant-comments.yml b/changelogs/unreleased/27614-instant-comments.yml new file mode 100644 index 00000000000..7b2592f46ed --- /dev/null +++ b/changelogs/unreleased/27614-instant-comments.yml @@ -0,0 +1,4 @@ +--- +title: Add support for instantly updating comments +merge_request: 10760 +author: diff --git a/changelogs/unreleased/28408-feature-proposal-include-search-options-to-pipelines-api.yml b/changelogs/unreleased/28408-feature-proposal-include-search-options-to-pipelines-api.yml new file mode 100644 index 00000000000..9b9f0032810 --- /dev/null +++ b/changelogs/unreleased/28408-feature-proposal-include-search-options-to-pipelines-api.yml @@ -0,0 +1,4 @@ +--- +title: 'API: Add parameters to allow filtering project pipelines' +merge_request: 9367 +author: dosuken123 diff --git a/changelogs/unreleased/28558-create-new-branch-from-issue-page.yml b/changelogs/unreleased/28558-create-new-branch-from-issue-page.yml new file mode 100644 index 00000000000..e43b043d6c5 --- /dev/null +++ b/changelogs/unreleased/28558-create-new-branch-from-issue-page.yml @@ -0,0 +1,4 @@ +--- +title: Allow to create new branch and empty WIP merge request from issue page +merge_request: +author: diff --git a/changelogs/unreleased/29145-oauth-422.yml b/changelogs/unreleased/29145-oauth-422.yml new file mode 100644 index 00000000000..94e4cd84ad1 --- /dev/null +++ b/changelogs/unreleased/29145-oauth-422.yml @@ -0,0 +1,4 @@ +--- +title: Redesign auth 422 page +merge_request: +author: diff --git a/changelogs/unreleased/29673-500-internal-server-error-when-enabling-a-deploy-key-more-than-once-through-api.yml b/changelogs/unreleased/29673-500-internal-server-error-when-enabling-a-deploy-key-more-than-once-through-api.yml new file mode 100644 index 00000000000..3e62ede1521 --- /dev/null +++ b/changelogs/unreleased/29673-500-internal-server-error-when-enabling-a-deploy-key-more-than-once-through-api.yml @@ -0,0 +1,4 @@ +--- +title: Detect already enabled DeployKeys in EnableDeployKeyService +merge_request: +author: diff --git a/changelogs/unreleased/29925-gitlab-shell-hooks-can-no-longer-send-absolute-paths-to-gitlab-ce.yml b/changelogs/unreleased/29925-gitlab-shell-hooks-can-no-longer-send-absolute-paths-to-gitlab-ce.yml new file mode 100644 index 00000000000..1df8f695ef1 --- /dev/null +++ b/changelogs/unreleased/29925-gitlab-shell-hooks-can-no-longer-send-absolute-paths-to-gitlab-ce.yml @@ -0,0 +1,4 @@ +--- +title: Generate and handle a gl_repository param to pass around components +merge_request: 10992 +author: diff --git a/changelogs/unreleased/30007-done-todo-hover-state.yml b/changelogs/unreleased/30007-done-todo-hover-state.yml new file mode 100644 index 00000000000..bfbde7a49c8 --- /dev/null +++ b/changelogs/unreleased/30007-done-todo-hover-state.yml @@ -0,0 +1,4 @@ +--- +title: Add transparent top-border to the hover state of done todos +merge_request: +author: diff --git a/changelogs/unreleased/30272-bvl-reject-more-namespaces.yml b/changelogs/unreleased/30272-bvl-reject-more-namespaces.yml new file mode 100644 index 00000000000..56bce084546 --- /dev/null +++ b/changelogs/unreleased/30272-bvl-reject-more-namespaces.yml @@ -0,0 +1,4 @@ +--- +title: Improve validation of namespace & project paths +merge_request: 10413 +author: diff --git a/changelogs/unreleased/30458-real-time-note-edits.yml b/changelogs/unreleased/30458-real-time-note-edits.yml new file mode 100644 index 00000000000..f67348c5302 --- /dev/null +++ b/changelogs/unreleased/30458-real-time-note-edits.yml @@ -0,0 +1,4 @@ +--- +title: Update note edits in real-time +merge_request: +author: diff --git a/changelogs/unreleased/30529-remove-pages-tab-if-pages-isn-t-enabled.yml b/changelogs/unreleased/30529-remove-pages-tab-if-pages-isn-t-enabled.yml new file mode 100644 index 00000000000..16938f05326 --- /dev/null +++ b/changelogs/unreleased/30529-remove-pages-tab-if-pages-isn-t-enabled.yml @@ -0,0 +1,4 @@ +--- +title: Disable navigation to Project-level pages configuration when Pages disabled +merge_request: 11008 +author: diff --git a/changelogs/unreleased/30667-creating-new-label-on-new-issue-causing-bug.yml b/changelogs/unreleased/30667-creating-new-label-on-new-issue-causing-bug.yml new file mode 100644 index 00000000000..ce0ea69211e --- /dev/null +++ b/changelogs/unreleased/30667-creating-new-label-on-new-issue-causing-bug.yml @@ -0,0 +1,4 @@ +--- +title: Fix label creation from issuable for subgroup projects +merge_request: +author: diff --git a/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml b/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml new file mode 100644 index 00000000000..af87e5ce39f --- /dev/null +++ b/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml @@ -0,0 +1,4 @@ +--- +title: Vertically align mini pipeline stage container +merge_request: +author: diff --git a/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml b/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml new file mode 100644 index 00000000000..42426c1865e --- /dev/null +++ b/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml @@ -0,0 +1,4 @@ +--- +title: Sort the network graph both by commit date and topographically +merge_request: 11057 +author: diff --git a/changelogs/unreleased/31057-unnecessary-padding-along-left-side-of-assignees-dropdown.yml b/changelogs/unreleased/31057-unnecessary-padding-along-left-side-of-assignees-dropdown.yml new file mode 100644 index 00000000000..0d82bf878c7 --- /dev/null +++ b/changelogs/unreleased/31057-unnecessary-padding-along-left-side-of-assignees-dropdown.yml @@ -0,0 +1,4 @@ +--- +title: Show checkmark on current assignee in assignee dropdown +merge_request: 10767 +author: diff --git a/changelogs/unreleased/31156-environments-vue-service.yml b/changelogs/unreleased/31156-environments-vue-service.yml new file mode 100644 index 00000000000..8b899ed9861 --- /dev/null +++ b/changelogs/unreleased/31156-environments-vue-service.yml @@ -0,0 +1,4 @@ +--- +title: Fix environments vue architecture to match documentation +merge_request: +author: diff --git a/changelogs/unreleased/31274-creating-schedule-trigger--causes-http-500-when-accessing-settings-ci_cd.yml b/changelogs/unreleased/31274-creating-schedule-trigger--causes-http-500-when-accessing-settings-ci_cd.yml new file mode 100644 index 00000000000..b0c33ab3fa4 --- /dev/null +++ b/changelogs/unreleased/31274-creating-schedule-trigger--causes-http-500-when-accessing-settings-ci_cd.yml @@ -0,0 +1,4 @@ +--- +title: Fix error on CI/CD Settings page related to invalid pipeline trigger +merge_request: 10948 +author: dosuken123 diff --git a/changelogs/unreleased/31383-admin-remove-user-text-incorrect.yml b/changelogs/unreleased/31383-admin-remove-user-text-incorrect.yml new file mode 100644 index 00000000000..a2a2c0c42bd --- /dev/null +++ b/changelogs/unreleased/31383-admin-remove-user-text-incorrect.yml @@ -0,0 +1,4 @@ +--- +title: Note Ghost user and refer to user deletion documentation +merge_request: +author: diff --git a/changelogs/unreleased/31544-size-of-project-from-api.yml b/changelogs/unreleased/31544-size-of-project-from-api.yml new file mode 100644 index 00000000000..a707d49aecd --- /dev/null +++ b/changelogs/unreleased/31544-size-of-project-from-api.yml @@ -0,0 +1,4 @@ +--- +title: Expose project statistics on single requests via the API +merge_request: +author: diff --git a/changelogs/unreleased/31558-job-dropdown.yml b/changelogs/unreleased/31558-job-dropdown.yml new file mode 100644 index 00000000000..acd7b2addb6 --- /dev/null +++ b/changelogs/unreleased/31558-job-dropdown.yml @@ -0,0 +1,4 @@ +--- +title: Job dropdown of pipeline mini graph updates in realtime when its opened +merge_request: +author: diff --git a/changelogs/unreleased/31560-workhose-gitaly-from-mirror.yml b/changelogs/unreleased/31560-workhose-gitaly-from-mirror.yml new file mode 100644 index 00000000000..02c048cb3b4 --- /dev/null +++ b/changelogs/unreleased/31560-workhose-gitaly-from-mirror.yml @@ -0,0 +1,4 @@ +--- +title: rickettm Add repo parameter to gitaly:install and workhorse:install rake tasks +merge_request: 10979 +author: M. Ricketts diff --git a/changelogs/unreleased/31647-fix-snippet-content_html.yml b/changelogs/unreleased/31647-fix-snippet-content_html.yml new file mode 100644 index 00000000000..db6d45926fd --- /dev/null +++ b/changelogs/unreleased/31647-fix-snippet-content_html.yml @@ -0,0 +1,4 @@ +--- +title: Fix caching large snippet HTML content on MySQL databases +merge_request: 11024 +author: diff --git a/changelogs/unreleased/31671-merge-request-message-contains-carriage-returns.yml b/changelogs/unreleased/31671-merge-request-message-contains-carriage-returns.yml new file mode 100644 index 00000000000..c33fa944a83 --- /dev/null +++ b/changelogs/unreleased/31671-merge-request-message-contains-carriage-returns.yml @@ -0,0 +1,4 @@ +--- +title: Remove carriage returns from commit messages +merge_request: 11077 +author: diff --git a/changelogs/unreleased/31689-request-access-spacing.yml b/changelogs/unreleased/31689-request-access-spacing.yml new file mode 100644 index 00000000000..66076b44f46 --- /dev/null +++ b/changelogs/unreleased/31689-request-access-spacing.yml @@ -0,0 +1,4 @@ +--- +title: Add default margin-top to user request table on project members page +merge_request: +author: diff --git a/changelogs/unreleased/31704-misaligned-buttons-in-wiki-pages.yml b/changelogs/unreleased/31704-misaligned-buttons-in-wiki-pages.yml new file mode 100644 index 00000000000..46368b4510e --- /dev/null +++ b/changelogs/unreleased/31704-misaligned-buttons-in-wiki-pages.yml @@ -0,0 +1,4 @@ +--- +title: Fix misaligned buttons in wiki pages +merge_request: 11043 +author: diff --git a/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml b/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml new file mode 100644 index 00000000000..9bbf43d652e --- /dev/null +++ b/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml @@ -0,0 +1,4 @@ +--- +title: Add tooltips to note action buttons +merge_request: +author: diff --git a/changelogs/unreleased/31810-commit-link.yml b/changelogs/unreleased/31810-commit-link.yml new file mode 100644 index 00000000000..857c9cb95c5 --- /dev/null +++ b/changelogs/unreleased/31810-commit-link.yml @@ -0,0 +1,4 @@ +--- +title: Remove `#` being added on commit sha in MR widget +merge_request: +author: diff --git a/changelogs/unreleased/add_system_note_for_editing_issuable.yml b/changelogs/unreleased/add_system_note_for_editing_issuable.yml new file mode 100644 index 00000000000..3cbc7f91bf0 --- /dev/null +++ b/changelogs/unreleased/add_system_note_for_editing_issuable.yml @@ -0,0 +1,4 @@ +--- +title: Add system note on description change of issue/merge request +merge_request: 10392 +author: blackst0ne diff --git a/changelogs/unreleased/always-show-latest-pipeline-in-commit-box.yml b/changelogs/unreleased/always-show-latest-pipeline-in-commit-box.yml new file mode 100644 index 00000000000..6aa0c89f6f7 --- /dev/null +++ b/changelogs/unreleased/always-show-latest-pipeline-in-commit-box.yml @@ -0,0 +1,4 @@ +--- +title: Always show the latest pipeline information in the commit box +merge_request: 11038 +author: diff --git a/changelogs/unreleased/async-milestone-tabs.yml b/changelogs/unreleased/async-milestone-tabs.yml new file mode 100644 index 00000000000..c199a95610c --- /dev/null +++ b/changelogs/unreleased/async-milestone-tabs.yml @@ -0,0 +1,4 @@ +--- +title: Load milestone tabs asynchronously to increase initial load performance +merge_request: +author: diff --git a/changelogs/unreleased/balsalmiq-support.yml b/changelogs/unreleased/balsalmiq-support.yml new file mode 100644 index 00000000000..56a0b4c83fa --- /dev/null +++ b/changelogs/unreleased/balsalmiq-support.yml @@ -0,0 +1,4 @@ +--- +title: Added balsamiq file viewer +merge_request: 10564 +author: diff --git a/changelogs/unreleased/commit-limited-container-width.yml b/changelogs/unreleased/commit-limited-container-width.yml new file mode 100644 index 00000000000..253646b13da --- /dev/null +++ b/changelogs/unreleased/commit-limited-container-width.yml @@ -0,0 +1,4 @@ +--- +title: Side-by-side view in commits correcly expands full window width +merge_request: +author: diff --git a/changelogs/unreleased/deploy-keys-load-async.yml b/changelogs/unreleased/deploy-keys-load-async.yml new file mode 100644 index 00000000000..e90910278e8 --- /dev/null +++ b/changelogs/unreleased/deploy-keys-load-async.yml @@ -0,0 +1,4 @@ +--- +title: Deploy keys load are loaded async +merge_request: +author: diff --git a/changelogs/unreleased/dm-artifact-blob-viewer.yml b/changelogs/unreleased/dm-artifact-blob-viewer.yml new file mode 100644 index 00000000000..38f5cbb73e1 --- /dev/null +++ b/changelogs/unreleased/dm-artifact-blob-viewer.yml @@ -0,0 +1,4 @@ +--- +title: Add artifact file page that uses the blob viewer +merge_request: +author: diff --git a/changelogs/unreleased/dm-artifact-browser-header.yml b/changelogs/unreleased/dm-artifact-browser-header.yml new file mode 100644 index 00000000000..b88ab2ac7e5 --- /dev/null +++ b/changelogs/unreleased/dm-artifact-browser-header.yml @@ -0,0 +1,4 @@ +--- +title: Add breadcrumb, build header and pipelines submenu to artifacts browser +merge_request: +author: diff --git a/changelogs/unreleased/dm-comment-on-diff-versions.yml b/changelogs/unreleased/dm-comment-on-diff-versions.yml new file mode 100644 index 00000000000..af299713ad3 --- /dev/null +++ b/changelogs/unreleased/dm-comment-on-diff-versions.yml @@ -0,0 +1,4 @@ +--- +title: Allow commenting on older versions of the diff and comparisons between diff versions +merge_request: +author: diff --git a/changelogs/unreleased/emoji-button-titles.yml b/changelogs/unreleased/emoji-button-titles.yml new file mode 100644 index 00000000000..c8e1b2c6c6b --- /dev/null +++ b/changelogs/unreleased/emoji-button-titles.yml @@ -0,0 +1,4 @@ +--- +title: Added title to award emoji buttons +merge_request: +author: diff --git a/changelogs/unreleased/fix-admin-integrations.yml b/changelogs/unreleased/fix-admin-integrations.yml new file mode 100644 index 00000000000..7689623501f --- /dev/null +++ b/changelogs/unreleased/fix-admin-integrations.yml @@ -0,0 +1,4 @@ +--- +title: Fix new admin integrations not taking effect on existing projects +merge_request: +author: diff --git a/changelogs/unreleased/fix-import-export-missing-attributes.yml b/changelogs/unreleased/fix-import-export-missing-attributes.yml new file mode 100644 index 00000000000..a1338b4eb48 --- /dev/null +++ b/changelogs/unreleased/fix-import-export-missing-attributes.yml @@ -0,0 +1,4 @@ +--- +title: Add missing project attributes to Import/Export +merge_request: +author: diff --git a/changelogs/unreleased/fix-n-plus-one-project-features.yml b/changelogs/unreleased/fix-n-plus-one-project-features.yml new file mode 100644 index 00000000000..1b19bd65224 --- /dev/null +++ b/changelogs/unreleased/fix-n-plus-one-project-features.yml @@ -0,0 +1,4 @@ +--- +title: Remove N+1 queries in processing MR references +merge_request: +author: diff --git a/changelogs/unreleased/implement-i18n-support.yml b/changelogs/unreleased/implement-i18n-support.yml new file mode 100644 index 00000000000..d304fbecf90 --- /dev/null +++ b/changelogs/unreleased/implement-i18n-support.yml @@ -0,0 +1,4 @@ +--- +title: Add support for i18n on Cycle Analytics page +merge_request: 10669 +author: diff --git a/changelogs/unreleased/issue-boards-no-avatar.yml b/changelogs/unreleased/issue-boards-no-avatar.yml new file mode 100644 index 00000000000..a2dd53b3f2f --- /dev/null +++ b/changelogs/unreleased/issue-boards-no-avatar.yml @@ -0,0 +1,4 @@ +--- +title: Fixed avatar not display on issue boards when Gravatar is disabled +merge_request: +author: diff --git a/changelogs/unreleased/issue-title-description-realtime.yml b/changelogs/unreleased/issue-title-description-realtime.yml new file mode 100644 index 00000000000..003e1a4ab33 --- /dev/null +++ b/changelogs/unreleased/issue-title-description-realtime.yml @@ -0,0 +1,4 @@ +--- +title: Add realtime descriptions to issue show pages +merge_request: +author: diff --git a/changelogs/unreleased/merge-request-poll-json-endpoint.yml b/changelogs/unreleased/merge-request-poll-json-endpoint.yml new file mode 100644 index 00000000000..6c41984e9b7 --- /dev/null +++ b/changelogs/unreleased/merge-request-poll-json-endpoint.yml @@ -0,0 +1,4 @@ +--- +title: Fixed bug where merge request JSON would be displayed +merge_request: +author: diff --git a/changelogs/unreleased/mrchrisw-import-shell-timeout.yml b/changelogs/unreleased/mrchrisw-import-shell-timeout.yml new file mode 100644 index 00000000000..e43409109d6 --- /dev/null +++ b/changelogs/unreleased/mrchrisw-import-shell-timeout.yml @@ -0,0 +1,4 @@ +--- +title: Add configurable timeout for git fetch and clone operations +merge_request: 10697 +author: diff --git a/changelogs/unreleased/preview-separate-slash-commands.yml b/changelogs/unreleased/preview-separate-slash-commands.yml new file mode 100644 index 00000000000..6240ccc957c --- /dev/null +++ b/changelogs/unreleased/preview-separate-slash-commands.yml @@ -0,0 +1,4 @@ +--- +title: Display slash commands outcome when previewing Markdown +merge_request: 10054 +author: Rares Sfirlogea diff --git a/changelogs/unreleased/prometheus-integration-test-setting-fix.yml b/changelogs/unreleased/prometheus-integration-test-setting-fix.yml new file mode 100644 index 00000000000..45b7c2263e6 --- /dev/null +++ b/changelogs/unreleased/prometheus-integration-test-setting-fix.yml @@ -0,0 +1,4 @@ +--- +title: Prevent 500 errors caused by testing the Prometheus service +merge_request: 10994 +author: diff --git a/changelogs/unreleased/tags-sort-default.yml b/changelogs/unreleased/tags-sort-default.yml new file mode 100644 index 00000000000..265b765d540 --- /dev/null +++ b/changelogs/unreleased/tags-sort-default.yml @@ -0,0 +1,4 @@ +--- +title: Fixed tags sort from defaulting to empty +merge_request: +author: diff --git a/changelogs/unreleased/tc-job-page-mr-bold.yml b/changelogs/unreleased/tc-job-page-mr-bold.yml new file mode 100644 index 00000000000..0243a259119 --- /dev/null +++ b/changelogs/unreleased/tc-job-page-mr-bold.yml @@ -0,0 +1,4 @@ +--- +title: Make MR link in build sidebar bold +merge_request: +author: diff --git a/changelogs/unreleased/winh-visual-token-labels.yml b/changelogs/unreleased/winh-visual-token-labels.yml new file mode 100644 index 00000000000..d4952e910b4 --- /dev/null +++ b/changelogs/unreleased/winh-visual-token-labels.yml @@ -0,0 +1,4 @@ +--- +title: Colorize labels in search field +merge_request: 11047 +author: diff --git a/changelogs/unreleased/zj-chat-message-pretty-time.yml b/changelogs/unreleased/zj-chat-message-pretty-time.yml new file mode 100644 index 00000000000..68bc647bab2 --- /dev/null +++ b/changelogs/unreleased/zj-chat-message-pretty-time.yml @@ -0,0 +1,4 @@ +--- +title: Pipeline chat notifications convert seconds to minutes and hours +merge_request: +author: diff --git a/changelogs/unreleased/zj-real-time-pipelines.yml b/changelogs/unreleased/zj-real-time-pipelines.yml new file mode 100644 index 00000000000..eec22e67467 --- /dev/null +++ b/changelogs/unreleased/zj-real-time-pipelines.yml @@ -0,0 +1,4 @@ +--- +title: Pipeline view updates in near real time +merge_request: 10777 +author: diff --git a/config/application.rb b/config/application.rb index f2ecc4ce77c..32ad2393648 100644 --- a/config/application.rb +++ b/config/application.rb @@ -40,6 +40,9 @@ module Gitlab # config.i18n.default_locale = :de config.i18n.enforce_available_locales = false + # Translation for AR attrs is not working well for POROs like WikiPage + config.gettext_i18n_rails.use_for_active_record_attributes = false + # Configure the default encoding used in templates for Ruby 1.9. config.encoding = "utf-8" diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index c2eaf263937..fa503f84dd0 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -502,6 +502,9 @@ production: &base upload_pack: true receive_pack: true + # Git import/fetch timeout + # git_timeout: 800 + # If you use non-standard ssh port you need to specify it # ssh_port: 22 diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 7a8f00f11b2..6a6fbe86df9 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -386,6 +386,7 @@ Settings.gitlab_shell['ssh_port'] ||= 22 Settings.gitlab_shell['ssh_user'] ||= Settings.gitlab.user Settings.gitlab_shell['owner_group'] ||= Settings.gitlab.user Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.__send__(:build_gitlab_shell_ssh_path_prefix) +Settings.gitlab_shell['git_timeout'] ||= 800 # # Workhorse diff --git a/config/initializers/active_record_query_trace.rb b/config/initializers/active_record_query_trace.rb deleted file mode 100644 index 4b3c2803b3b..00000000000 --- a/config/initializers/active_record_query_trace.rb +++ /dev/null @@ -1,5 +0,0 @@ -if ENV['ENABLE_QUERY_TRACE'] - require 'active_record_query_trace' - - ActiveRecordQueryTrace.enabled = 'true' -end diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb new file mode 100644 index 00000000000..a69fe0c902e --- /dev/null +++ b/config/initializers/fast_gettext.rb @@ -0,0 +1,5 @@ +FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po +FastGettext.default_text_domain = 'gitlab' +FastGettext.default_available_locales = Gitlab::I18n.available_locales + +I18n.available_locales = Gitlab::I18n.available_locales diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb new file mode 100644 index 00000000000..69118f464ca --- /dev/null +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -0,0 +1,42 @@ +require 'gettext_i18n_rails/haml_parser' +require 'gettext_i18n_rails_js/parser/javascript' + +VUE_TRANSLATE_REGEX = /((%[\w.-]+)(?:\s))?{{ (N|n|s)?__\((.*)\) }}/ + +module GettextI18nRails + class HamlParser + singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code) + + # We need to convert text in Mustache format + # to a format that can be parsed by Gettext scripts. + # If we found a content like "{{ __('Stage') }}" + # in a HAML file we convert it to "= _('Stage')", that way + # it can be processed by the "rake gettext:find" script. + # + # Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9 + def self.convert_to_code(text) + text.gsub!(VUE_TRANSLATE_REGEX, "\\2= \\3_(\\4)") + + old_convert_to_code(text) + end + end +end + +module GettextI18nRailsJs + module Parser + module Javascript + # This is required to tell the `rake gettext:find` script to use the Javascript + # parser for *.vue files. + # + # Overwrites: https://github.com/webhippie/gettext_i18n_rails_js/blob/46c58db6d2053a4f5f36a0eb024ea706ff5707cb/lib/gettext_i18n_rails_js/parser/javascript.rb#L36 + def target?(file) + [ + ".js", + ".jsx", + ".coffee", + ".vue" + ].include? ::File.extname(file) + end + end + end +end diff --git a/config/locales/de.yml b/config/locales/de.yml new file mode 100644 index 00000000000..533663a2704 --- /dev/null +++ b/config/locales/de.yml @@ -0,0 +1,219 @@ +--- +de: + activerecord: + errors: + messages: + record_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}' + restrict_dependent_destroy: + has_one: Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz + existiert. + has_many: Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren. + date: + abbr_day_names: + - So + - Mo + - Di + - Mi + - Do + - Fr + - Sa + abbr_month_names: + - + - Jan + - Feb + - Mär + - Apr + - Mai + - Jun + - Jul + - Aug + - Sep + - Okt + - Nov + - Dez + day_names: + - Sonntag + - Montag + - Dienstag + - Mittwoch + - Donnerstag + - Freitag + - Samstag + formats: + default: "%d.%m.%Y" + long: "%e. %B %Y" + short: "%e. %b" + month_names: + - + - Januar + - Februar + - März + - April + - Mai + - Juni + - Juli + - August + - September + - Oktober + - November + - Dezember + order: + - :day + - :month + - :year + datetime: + distance_in_words: + about_x_hours: + one: etwa eine Stunde + other: etwa %{count} Stunden + about_x_months: + one: etwa ein Monat + other: etwa %{count} Monate + about_x_years: + one: etwa ein Jahr + other: etwa %{count} Jahre + almost_x_years: + one: fast ein Jahr + other: fast %{count} Jahre + half_a_minute: eine halbe Minute + less_than_x_minutes: + one: weniger als eine Minute + other: weniger als %{count} Minuten + less_than_x_seconds: + one: weniger als eine Sekunde + other: weniger als %{count} Sekunden + over_x_years: + one: mehr als ein Jahr + other: mehr als %{count} Jahre + x_days: + one: ein Tag + other: "%{count} Tage" + x_minutes: + one: eine Minute + other: "%{count} Minuten" + x_months: + one: ein Monat + other: "%{count} Monate" + x_seconds: + one: eine Sekunde + other: "%{count} Sekunden" + prompts: + day: Tag + hour: Stunden + minute: Minute + month: Monat + second: Sekunde + year: Jahr + errors: + format: "%{attribute} %{message}" + messages: + accepted: muss akzeptiert werden + blank: muss ausgefüllt werden + present: darf nicht ausgefüllt werden + confirmation: stimmt nicht mit %{attribute} überein + empty: muss ausgefüllt werden + equal_to: muss genau %{count} sein + even: muss gerade sein + exclusion: ist nicht verfügbar + greater_than: muss größer als %{count} sein + greater_than_or_equal_to: muss größer oder gleich %{count} sein + inclusion: ist kein gültiger Wert + invalid: ist nicht gültig + less_than: muss kleiner als %{count} sein + less_than_or_equal_to: muss kleiner oder gleich %{count} sein + model_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}' + not_a_number: ist keine Zahl + not_an_integer: muss ganzzahlig sein + odd: muss ungerade sein + required: muss ausgefüllt werden + taken: ist bereits vergeben + too_long: + one: ist zu lang (mehr als 1 Zeichen) + other: ist zu lang (mehr als %{count} Zeichen) + too_short: + one: ist zu kurz (weniger als 1 Zeichen) + other: ist zu kurz (weniger als %{count} Zeichen) + wrong_length: + one: hat die falsche Länge (muss genau 1 Zeichen haben) + other: hat die falsche Länge (muss genau %{count} Zeichen haben) + other_than: darf nicht gleich %{count} sein + template: + body: 'Bitte überprüfen Sie die folgenden Felder:' + header: + one: 'Konnte %{model} nicht speichern: ein Fehler.' + other: 'Konnte %{model} nicht speichern: %{count} Fehler.' + helpers: + select: + prompt: Bitte wählen + submit: + create: "%{model} erstellen" + submit: "%{model} speichern" + update: "%{model} aktualisieren" + number: + currency: + format: + delimiter: "." + format: "%n %u" + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + unit: "€" + format: + delimiter: "." + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + human: + decimal_units: + format: "%n %u" + units: + billion: + one: Milliarde + other: Milliarden + million: + one: Million + other: Millionen + quadrillion: + one: Billiarde + other: Billiarden + thousand: Tausend + trillion: + one: Billion + other: Billionen + unit: '' + format: + delimiter: '' + precision: 3 + significant: true + strip_insignificant_zeros: true + storage_units: + format: "%n %u" + units: + byte: + one: Byte + other: Bytes + gb: GB + kb: KB + mb: MB + tb: TB + percentage: + format: + delimiter: '' + format: "%n %" + precision: + format: + delimiter: '' + support: + array: + last_word_connector: " und " + two_words_connector: " und " + words_connector: ", " + time: + am: vormittags + formats: + default: "%A, %d. %B %Y, %H:%M Uhr" + long: "%A, %d. %B %Y, %H:%M Uhr" + short: "%d. %B, %H:%M Uhr" + pm: nachmittags diff --git a/config/locales/es.yml b/config/locales/es.yml new file mode 100644 index 00000000000..87e79beee74 --- /dev/null +++ b/config/locales/es.yml @@ -0,0 +1,217 @@ +--- +es: + activerecord: + errors: + messages: + record_invalid: "La validación falló: %{errors}" + restrict_dependent_destroy: + has_one: No se puede eliminar el registro porque existe un %{record} dependiente + has_many: No se puede eliminar el registro porque existen %{record} dependientes + date: + abbr_day_names: + - dom + - lun + - mar + - mié + - jue + - vie + - sáb + abbr_month_names: + - + - ene + - feb + - mar + - abr + - may + - jun + - jul + - ago + - sep + - oct + - nov + - dic + day_names: + - domingo + - lunes + - martes + - miércoles + - jueves + - viernes + - sábado + formats: + default: "%d/%m/%Y" + long: "%d de %B de %Y" + short: "%d de %b" + month_names: + - + - enero + - febrero + - marzo + - abril + - mayo + - junio + - julio + - agosto + - septiembre + - octubre + - noviembre + - diciembre + order: + - :day + - :month + - :year + datetime: + distance_in_words: + about_x_hours: + one: alrededor de 1 hora + other: alrededor de %{count} horas + about_x_months: + one: alrededor de 1 mes + other: alrededor de %{count} meses + about_x_years: + one: alrededor de 1 año + other: alrededor de %{count} años + almost_x_years: + one: casi 1 año + other: casi %{count} años + half_a_minute: medio minuto + less_than_x_minutes: + one: menos de 1 minuto + other: menos de %{count} minutos + less_than_x_seconds: + one: menos de 1 segundo + other: menos de %{count} segundos + over_x_years: + one: más de 1 año + other: más de %{count} años + x_days: + one: 1 día + other: "%{count} días" + x_minutes: + one: 1 minuto + other: "%{count} minutos" + x_months: + one: 1 mes + other: "%{count} meses" + x_years: + one: 1 año + other: "%{count} años" + x_seconds: + one: 1 segundo + other: "%{count} segundos" + prompts: + day: Día + hour: Hora + minute: Minutos + month: Mes + second: Segundos + year: Año + errors: + format: "%{attribute} %{message}" + messages: + accepted: debe ser aceptado + blank: no puede estar en blanco + present: debe estar en blanco + confirmation: no coincide + empty: no puede estar vacío + equal_to: debe ser igual a %{count} + even: debe ser par + exclusion: está reservado + greater_than: debe ser mayor que %{count} + greater_than_or_equal_to: debe ser mayor que o igual a %{count} + inclusion: no está incluido en la lista + invalid: no es válido + less_than: debe ser menor que %{count} + less_than_or_equal_to: debe ser menor que o igual a %{count} + model_invalid: "La validación falló: %{errors}" + not_a_number: no es un número + not_an_integer: debe ser un entero + odd: debe ser impar + required: debe existir + taken: ya está en uso + too_long: + one: "es demasiado largo (1 carácter máximo)" + other: "es demasiado largo (%{count} caracteres máximo)" + too_short: + one: "es demasiado corto (1 carácter mínimo)" + other: "es demasiado corto (%{count} caracteres mínimo)" + wrong_length: + one: "no tiene la longitud correcta (1 carácter exactos)" + other: "no tiene la longitud correcta (%{count} caracteres exactos)" + other_than: debe ser distinto de %{count} + template: + body: 'Se encontraron problemas con los siguientes campos:' + header: + one: No se pudo guardar este/a %{model} porque se encontró 1 error + other: No se pudo guardar este/a %{model} porque se encontraron %{count} errores + helpers: + select: + prompt: Por favor seleccione + submit: + create: Crear %{model} + submit: Guardar %{model} + update: Actualizar %{model} + number: + currency: + format: + delimiter: "." + format: "%n %u" + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + unit: "€" + format: + delimiter: "." + precision: 3 + separator: "," + significant: false + strip_insignificant_zeros: false + human: + decimal_units: + format: "%n %u" + units: + billion: mil millones + million: + one: millón + other: millones + quadrillion: mil billones + thousand: mil + trillion: + one: billón + other: billones + unit: '' + format: + delimiter: '' + precision: 1 + significant: true + strip_insignificant_zeros: true + storage_units: + format: "%n %u" + units: + byte: + one: Byte + other: Bytes + gb: GB + kb: KB + mb: MB + tb: TB + percentage: + format: + delimiter: '' + format: "%n %" + precision: + format: + delimiter: '' + support: + array: + last_word_connector: " y " + two_words_connector: " y " + words_connector: ", " + time: + am: am + formats: + default: "%A, %d de %B de %Y %H:%M:%S %z" + long: "%d de %B de %Y %H:%M" + short: "%d de %b %H:%M" + pm: pm diff --git a/config/routes/group.rb b/config/routes/group.rb index 73f69d76995..7b29e0e807c 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -10,7 +10,13 @@ scope(path: 'groups/*group_id', end resource :avatar, only: [:destroy] - resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] + resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] do + member do + get :merge_requests + get :participants + get :labels + end + end resources :labels, except: [:show] do post :toggle_subscription, on: :member diff --git a/config/routes/project.rb b/config/routes/project.rb index aaad503eba4..68474a44368 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -123,6 +123,7 @@ constraints(ProjectUrlConstrainer.new) do post :cancel post :retry get :builds + get :failures get :status end end @@ -138,6 +139,8 @@ constraints(ProjectUrlConstrainer.new) do collection do get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ } end + + resources :deployments, only: [:index] end resource :cycle_analytics, only: [:show] @@ -181,6 +184,7 @@ constraints(ProjectUrlConstrainer.new) do get :download get :browse, path: 'browse(/*path)', format: false get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false post :keep end end @@ -205,6 +209,9 @@ constraints(ProjectUrlConstrainer.new) do member do put :sort_issues put :sort_merge_requests + get :merge_requests + get :participants + get :labels end end @@ -229,6 +236,7 @@ constraints(ProjectUrlConstrainer.new) do get :related_branches get :can_create_branch get :rendered_title + post :create_merge_request end collection do post :bulk_update diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb index 2b22148a134..b315186b178 100644 --- a/config/routes/uploads.rb +++ b/config/routes/uploads.rb @@ -4,6 +4,11 @@ scope path: :uploads do to: "uploads#show", constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ } + # show uploads for models, snippets (notes) available for now + get ':model/:id/:secret/:filename', + to: 'uploads#show', + constraints: { model: /personal_snippet/, id: /\d+/, filename: /[^\/]+/ } + # Appearance get ":model/:mounted_as/:id/:filename", to: "uploads#show", @@ -13,6 +18,12 @@ scope path: :uploads do get ":namespace_id/:project_id/:secret/:filename", to: "projects/uploads#show", constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ } + + # create uploads for models, snippets (notes) available for now + post ':model/:id/', + to: 'uploads#create', + constraints: { model: /personal_snippet/, id: /\d+/ }, + as: 'upload' end # Redirect old note attachments path to new uploads path. diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index c3bd73533d0..433381e79d3 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -53,3 +53,4 @@ - [pages, 1] - [system_hook_push, 1] - [update_user_activity, 1] + - [propagate_service_template, 1] diff --git a/config/webpack.config.js b/config/webpack.config.js index 0ec9e48845e..119b1ea9d2e 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -17,6 +17,10 @@ var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; var WEBPACK_REPORT = process.env.WEBPACK_REPORT; var config = { + // because sqljs requires fs. + node: { + fs: "empty" + }, context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: { blob: './blob_edit/blob_bundle.js', @@ -26,6 +30,7 @@ var config = { common_d3: ['d3'], cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', commit_pipelines: './commit/pipelines/pipelines_bundle.js', + deploy_keys: './deploy_keys/index.js', diff_notes: './diff_notes/diff_notes_bundle.js', environments: './environments/environments_bundle.js', environments_folder: './environments/folder/environments_folder_bundle.js', @@ -33,8 +38,8 @@ var config = { graphs: './graphs/graphs_bundle.js', group: './group.js', groups_list: './groups_list.js', - issuable: './issuable/issuable_bundle.js', issue_show: './issue_show/index.js', + locale: './locale/index.js', main: './main.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_request_widget: './merge_request_widget/ci_bundle.js', @@ -43,15 +48,18 @@ var config = { notebook_viewer: './blob/notebook_viewer.js', pdf_viewer: './blob/pdf_viewer.js', pipelines: './pipelines/index.js', + balsamiq_viewer: './blob/balsamiq_viewer.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', + sidebar: './sidebar/sidebar_bundle.js', snippet: './snippet/snippet_bundle.js', sketch_viewer: './blob/sketch_viewer.js', stl_viewer: './blob/stl_viewer.js', terminal: './terminal/terminal_bundle.js', u2f: ['vendor/u2f'], users: './users/users_bundle.js', + raven: './raven/index.js', }, output: { @@ -87,6 +95,10 @@ var config = { exclude: /node_modules/, loader: 'file-loader', }, + { + test: /locale\/[a-z]+\/(.*)\.js$/, + loader: 'exports-loader?locales', + }, ] }, @@ -122,15 +134,17 @@ var config = { 'boards', 'commit_pipelines', 'cycle_analytics', + 'deploy_keys', 'diff_notes', 'environments', 'environments_folder', - 'issuable', + 'sidebar', 'issue_show', 'merge_conflicts', 'notebook_viewer', 'pdf_viewer', 'pipelines', + 'balsamiq_viewer', ], minChunks: function(module, count) { return module.resource && (/vue_shared/).test(module.resource); @@ -151,6 +165,14 @@ var config = { new webpack.optimize.CommonsChunkPlugin({ names: ['main', 'common', 'runtime'], }), + + // locale common library + new webpack.optimize.CommonsChunkPlugin({ + name: 'locale', + chunks: [ + 'cycle_analytics', + ], + }), ], resolve: { diff --git a/db/fixtures/development/09_issues.rb b/db/fixtures/development/09_issues.rb index d93d133d157..0b32a461d56 100644 --- a/db/fixtures/development/09_issues.rb +++ b/db/fixtures/development/09_issues.rb @@ -8,7 +8,7 @@ Gitlab::Seeder.quiet do description: FFaker::Lorem.sentence, state: ['opened', 'closed'].sample, milestone: project.milestones.sample, - assignee: project.team.users.sample + assignees: [project.team.users.sample] } Issues::CreateService.new(project, project.team.users.sample, issue_params).execute diff --git a/db/migrate/20170320171632_create_issue_assignees_table.rb b/db/migrate/20170320171632_create_issue_assignees_table.rb new file mode 100644 index 00000000000..23b8da37b6d --- /dev/null +++ b/db/migrate/20170320171632_create_issue_assignees_table.rb @@ -0,0 +1,40 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CreateIssueAssigneesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + INDEX_NAME = 'index_issue_assignees_on_issue_id_and_user_id' + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def up + create_table :issue_assignees do |t| + t.references :user, foreign_key: { on_delete: :cascade }, index: true, null: false + t.references :issue, foreign_key: { on_delete: :cascade }, null: false + end + + add_index :issue_assignees, [:issue_id, :user_id], unique: true, name: INDEX_NAME + end + + def down + drop_table :issue_assignees + end +end diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb new file mode 100644 index 00000000000..ba8edbd7d32 --- /dev/null +++ b/db/migrate/20170320173259_migrate_assignees.rb @@ -0,0 +1,52 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class MigrateAssignees < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + # Optimisation: this accounts for most of the invalid assignee IDs on GitLab.com + update_column_in_batches(:issues, :assignee_id, nil) do |table, query| + query.where(table[:assignee_id].eq(0)) + end + + users = Arel::Table.new(:users) + + update_column_in_batches(:issues, :assignee_id, nil) do |table, query| + query.where(table[:assignee_id].not_eq(nil)\ + .and( + users.project("true").where(users[:id].eq(table[:assignee_id])).exists.not + )) + end + + execute <<-EOF + INSERT INTO issue_assignees(issue_id, user_id) + SELECT id, assignee_id FROM issues WHERE assignee_id IS NOT NULL + EOF + end + + def down + execute <<-EOF + DELETE FROM issue_assignees + EOF + end +end diff --git a/db/migrate/20170327091750_add_created_at_index_to_deployments.rb b/db/migrate/20170327091750_add_created_at_index_to_deployments.rb new file mode 100644 index 00000000000..fd6ed499b80 --- /dev/null +++ b/db/migrate/20170327091750_add_created_at_index_to_deployments.rb @@ -0,0 +1,15 @@ +class AddCreatedAtIndexToDeployments < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :deployments, :created_at + end + + def down + remove_concurrent_index :deployments, :created_at + end +end diff --git a/db/migrate/20170413035209_add_preferred_language_to_users.rb b/db/migrate/20170413035209_add_preferred_language_to_users.rb new file mode 100644 index 00000000000..92f1d6f2436 --- /dev/null +++ b/db/migrate/20170413035209_add_preferred_language_to_users.rb @@ -0,0 +1,16 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddPreferredLanguageToUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + add_column :users, :preferred_language, :string + end + + def down + remove_column :users, :preferred_language + end +end diff --git a/db/migrate/20170427215854_create_redirect_routes.rb b/db/migrate/20170427215854_create_redirect_routes.rb new file mode 100644 index 00000000000..2bf086b3e30 --- /dev/null +++ b/db/migrate/20170427215854_create_redirect_routes.rb @@ -0,0 +1,14 @@ +class CreateRedirectRoutes < ActiveRecord::Migration + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + create_table :redirect_routes do |t| + t.integer :source_id, null: false + t.string :source_type, null: false + t.string :path, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb b/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb new file mode 100644 index 00000000000..008a94d8334 --- /dev/null +++ b/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb @@ -0,0 +1,2 @@ +# rubocop:disable all +require_relative 'markdown_cache_limits_to_mysql' diff --git a/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb b/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb new file mode 100644 index 00000000000..00c685cf342 --- /dev/null +++ b/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb @@ -0,0 +1,7 @@ +class AddLastRepositoryUpdatedAtToProjects < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :projects, :last_repository_updated_at, :datetime + end +end diff --git a/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb b/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb new file mode 100644 index 00000000000..6144d74745c --- /dev/null +++ b/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb @@ -0,0 +1,15 @@ +class AddIndexToLastRepositoryUpdatedAtOnProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(:projects, :last_repository_updated_at) + end + + def down + remove_concurrent_index(:projects, :last_repository_updated_at) if index_exists?(:projects, :last_repository_updated_at) + end +end diff --git a/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb b/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb new file mode 100644 index 00000000000..6ac10723c82 --- /dev/null +++ b/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLastEditedAtAndLastEditedByIdToIssues < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :issues, :last_edited_at, :timestamp + add_column :issues, :last_edited_by_id, :integer + end +end diff --git a/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb b/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb new file mode 100644 index 00000000000..7a1acdcbf69 --- /dev/null +++ b/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLastEditedAtAndLastEditedByIdToMergeRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :merge_requests, :last_edited_at, :timestamp + add_column :merge_requests, :last_edited_by_id, :integer + end +end diff --git a/db/migrate/20170503184421_add_index_to_redirect_routes.rb b/db/migrate/20170503184421_add_index_to_redirect_routes.rb new file mode 100644 index 00000000000..9062cf19a73 --- /dev/null +++ b/db/migrate/20170503184421_add_index_to_redirect_routes.rb @@ -0,0 +1,21 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddIndexToRedirectRoutes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(:redirect_routes, :path, unique: true) + add_concurrent_index(:redirect_routes, [:source_type, :source_id]) + end + + def down + remove_concurrent_index(:redirect_routes, :path) if index_exists?(:redirect_routes, :path) + remove_concurrent_index(:redirect_routes, [:source_type, :source_id]) if index_exists?(:redirect_routes, [:source_type, :source_id]) + end +end diff --git a/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb new file mode 100644 index 00000000000..5b8b6c828be --- /dev/null +++ b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class IndexRedirectRoutesPathForLike < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + INDEX_NAME = 'index_redirect_routes_on_path_text_pattern_ops' + + disable_ddl_transaction! + + def up + return unless Gitlab::Database.postgresql? + + unless index_exists?(:redirect_routes, :path, name: INDEX_NAME) + execute("CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON redirect_routes (path varchar_pattern_ops);") + end + end + + def down + return unless Gitlab::Database.postgresql? + + if index_exists?(:redirect_routes, :path, name: INDEX_NAME) + execute("DROP INDEX CONCURRENTLY #{INDEX_NAME};") + end + end +end diff --git a/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb new file mode 100644 index 00000000000..141112f8b50 --- /dev/null +++ b/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb @@ -0,0 +1,33 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false + add_column :application_settings, :clientside_sentry_dsn, :string + end + + def down + remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn + end +end diff --git a/db/migrate/markdown_cache_limits_to_mysql.rb b/db/migrate/markdown_cache_limits_to_mysql.rb new file mode 100644 index 00000000000..f6686db3dc0 --- /dev/null +++ b/db/migrate/markdown_cache_limits_to_mysql.rb @@ -0,0 +1,13 @@ +class MarkdownCacheLimitsToMysql < ActiveRecord::Migration + DOWNTIME = false + + def up + return unless Gitlab::Database.mysql? + + change_column :snippets, :content_html, :text, limit: 2147483647 + end + + def down + # no-op + end +end diff --git a/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb new file mode 100644 index 00000000000..08cf366f0a1 --- /dev/null +++ b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb @@ -0,0 +1,62 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RenameReservedDynamicPaths < ActiveRecord::Migration + include Gitlab::Database::RenameReservedPathsMigration::V1 + + DOWNTIME = false + + disable_ddl_transaction! + + DISALLOWED_ROOT_PATHS = %w[ + - + abuse_reports + api + autocomplete + explore + health_check + import + invites + jwt + koding + member + notification_settings + oauth + sent_notifications + unicorn_test + uploads + users + ] + + DISALLOWED_WILDCARD_PATHS = %w[ + environments/folders + gitlab-lfs/objects + info/lfs/objects + ] + + DISSALLOWED_GROUP_PATHS = %w[ + activity + analytics + audit_events + avatar + group_members + hooks + labels + ldap + ldap_group_links + milestones + notification_setting + pipeline_quota + subgroups + ] + + def up + rename_root_paths(DISALLOWED_ROOT_PATHS) + rename_wildcard_paths(DISALLOWED_WILDCARD_PATHS) + rename_child_paths(DISSALLOWED_GROUP_PATHS) + end + + def down + # nothing to do + end +end diff --git a/db/schema.rb b/db/schema.rb index d411a137b70..289ea8f1eca 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170502140503) do +ActiveRecord::Schema.define(version: 20170504102911) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -121,6 +121,8 @@ ActiveRecord::Schema.define(version: 20170502140503) do t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" + t.boolean "clientside_sentry_enabled", default: false, null: false + t.string "clientside_sentry_dsn" end create_table "audit_events", force: :cascade do |t| @@ -388,6 +390,7 @@ ActiveRecord::Schema.define(version: 20170502140503) do t.string "on_stop" end + add_index "deployments", ["created_at"], name: "index_deployments_on_created_at", using: :btree add_index "deployments", ["project_id", "environment_id", "iid"], name: "index_deployments_on_project_id_and_environment_id_and_iid", using: :btree add_index "deployments", ["project_id", "iid"], name: "index_deployments_on_project_id_and_iid", unique: true, using: :btree @@ -453,6 +456,14 @@ ActiveRecord::Schema.define(version: 20170502140503) do add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree + create_table "issue_assignees", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "issue_id", null: false + end + + add_index "issue_assignees", ["issue_id", "user_id"], name: "index_issue_assignees_on_issue_id_and_user_id", unique: true, using: :btree + add_index "issue_assignees", ["user_id"], name: "index_issue_assignees_on_user_id", using: :btree + create_table "issue_metrics", force: :cascade do |t| t.integer "issue_id", null: false t.datetime "first_mentioned_in_commit_at" @@ -489,6 +500,8 @@ ActiveRecord::Schema.define(version: 20170502140503) do t.integer "relative_position" t.datetime "closed_at" t.integer "cached_markdown_version" + t.datetime "last_edited_at" + t.integer "last_edited_by_id" end add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree @@ -675,6 +688,8 @@ ActiveRecord::Schema.define(version: 20170502140503) do t.text "description_html" t.integer "time_estimate" t.integer "cached_markdown_version" + t.datetime "last_edited_at" + t.integer "last_edited_by_id" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -972,6 +987,7 @@ ActiveRecord::Schema.define(version: 20170502140503) do t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" t.integer "cached_markdown_version" + t.datetime "last_repository_updated_at" end add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree @@ -980,6 +996,7 @@ ActiveRecord::Schema.define(version: 20170502140503) do add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree add_index "projects", ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed", using: :btree + add_index "projects", ["last_repository_updated_at"], name: "index_projects_on_last_repository_updated_at", using: :btree add_index "projects", ["name"], name: "index_projects_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} add_index "projects", ["namespace_id"], name: "index_projects_on_namespace_id", using: :btree add_index "projects", ["path"], name: "index_projects_on_path", using: :btree @@ -1037,6 +1054,18 @@ ActiveRecord::Schema.define(version: 20170502140503) do add_index "protected_tags", ["project_id"], name: "index_protected_tags_on_project_id", using: :btree + create_table "redirect_routes", force: :cascade do |t| + t.integer "source_id", null: false + t.string "source_type", null: false + t.string "path", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path", unique: true, using: :btree + add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path_text_pattern_ops", using: :btree, opclasses: {"path"=>"varchar_pattern_ops"} + add_index "redirect_routes", ["source_type", "source_id"], name: "index_redirect_routes_on_source_type_and_source_id", using: :btree + create_table "releases", force: :cascade do |t| t.string "tag" t.text "description" @@ -1327,6 +1356,7 @@ ActiveRecord::Schema.define(version: 20170502140503) do t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false + t.string "preferred_language" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree @@ -1384,6 +1414,8 @@ ActiveRecord::Schema.define(version: 20170502140503) do add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade + add_foreign_key "issue_assignees", "issues", on_delete: :cascade + add_foreign_key "issue_assignees", "users", on_delete: :cascade add_foreign_key "container_repositories", "projects" add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade @@ -1411,4 +1443,4 @@ ActiveRecord::Schema.define(version: 20170502140503) do add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "u2f_registrations", "users" -end +end
\ No newline at end of file diff --git a/doc/README.md b/doc/README.md index 6406040da4b..4397465bd3d 100644 --- a/doc/README.md +++ b/doc/README.md @@ -92,7 +92,7 @@ Take a step ahead and dive into GitLab's advanced features. - [GitLab Pages](user/project/pages/index.md): Build, test, and deploy your static website with GitLab Pages. - [Snippets](user/snippets.md): Snippets allow you to create little bits of code. -- [Wikis](workflow/project_features.md#wiki): Enhance your repository documentation with built-in wikis. +- [Wikis](user/project/wiki/index.md): Enhance your repository documentation with built-in wikis. ### Continuous Integration, Delivery, and Deployment diff --git a/doc/api/issues.md b/doc/api/issues.md index 6c10b5ab0e7..1d43b1298b9 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -70,6 +70,14 @@ Example response: "updated_at" : "2016-01-04T15:31:39.996Z" }, "project_id" : 1, + "assignees" : [{ + "state" : "active", + "id" : 1, + "name" : "Administrator", + "web_url" : "https://gitlab.example.com/root", + "avatar_url" : null, + "username" : "root" + }], "assignee" : { "state" : "active", "id" : 1, @@ -92,6 +100,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## List group issues Get a list of a group's issues. @@ -153,6 +163,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -174,6 +192,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## List project issues Get a list of a project's issues. @@ -235,6 +255,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -256,6 +284,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Single issue Get a single project issue. @@ -300,6 +330,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -321,6 +359,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## New issue Creates a new project issue. @@ -329,13 +369,13 @@ Creates a new project issue. POST /projects/:id/issues ``` -| Attribute | Type | Required | Description | -|-------------------------------------------|---------|----------|--------------| +| Attribute | Type | Required | Description | +|-------------------------------------------|----------------|----------|--------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `title` | string | yes | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | -| `assignee_id` | integer | no | The ID of a user to assign issue | +| `assignee_ids` | Array[integer] | no | The ID of a user to assign issue | | `milestone_id` | integer | no | The ID of a milestone to assign issue | | `labels` | string | no | Comma-separated label names for an issue | | `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | @@ -357,6 +397,7 @@ Example response: "iid" : 14, "title" : "Issues with auth", "state" : "opened", + "assignees" : [], "assignee" : null, "labels" : [ "bug" @@ -380,6 +421,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Edit issue Updates an existing project issue. This call is also used to mark an issue as @@ -396,7 +439,7 @@ PUT /projects/:id/issues/:issue_iid | `title` | string | no | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Updates an issue to be confidential | -| `assignee_id` | integer | no | The ID of a user to assign the issue to | +| `assignee_ids` | Array[integer] | no | The ID of a user to assign the issue to | | `milestone_id` | integer | no | The ID of a milestone to assign the issue to | | `labels` | string | no | Comma-separated label names for an issue | | `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | @@ -430,6 +473,7 @@ Example response: "bug" ], "id" : 85, + "assignees" : [], "assignee" : null, "milestone" : null, "subscribed" : true, @@ -440,6 +484,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Delete an issue Only for admins and project owners. Soft deletes the issue in question. @@ -494,6 +540,14 @@ Example response: "updated_at": "2016-04-07T12:20:17.596Z", "labels": [], "milestone": null, + "assignees": [{ + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block" + }], "assignee": { "name": "Miss Monserrate Beier", "username": "axel.block", @@ -516,6 +570,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Subscribe to an issue Subscribes the authenticated user to an issue to receive notifications. @@ -549,6 +605,14 @@ Example response: "updated_at": "2016-04-07T12:20:17.596Z", "labels": [], "milestone": null, + "assignees": [{ + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block" + }], "assignee": { "name": "Miss Monserrate Beier", "username": "axel.block", @@ -571,6 +635,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Unsubscribe from an issue Unsubscribes the authenticated user from the issue to not receive notifications @@ -652,6 +718,14 @@ Example response: "updated_at": "2016-06-17T07:47:33.832Z", "due_date": null }, + "assignees": [{ + "name": "Jarret O'Keefe", + "username": "francisca", + "id": 14, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/a7fa515d53450023c83d62986d0658a8?s=80&d=identicon", + "web_url": "https://gitlab.example.com/francisca" + }], "assignee": { "name": "Jarret O'Keefe", "username": "francisca", @@ -683,6 +757,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Set a time estimate for an issue Sets an estimated time of work for this issue. diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md index 732ad8da4ac..890945cfc7e 100644 --- a/doc/api/pipelines.md +++ b/doc/api/pipelines.md @@ -11,6 +11,14 @@ GET /projects/:id/pipelines | Attribute | Type | Required | Description | |-----------|---------|----------|---------------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `scope` | string | no | The scope of pipelines, one of: `running`, `pending`, `finished`, `branches`, `tags` | +| `status` | string | no | The status of pipelines, one of: `running`, `pending`, `success`, `failed`, `canceled`, `skipped` | +| `ref` | string | no | The ref of pipelines | +| `yaml_errors`| boolean | no | Returns pipelines with invalid configurations | +| `name`| string | no | The name of the user who triggered pipelines | +| `username`| string | no | The username of the user who triggered pipelines | +| `order_by`| string | no | Order pipelines by `id`, `status`, `ref`, or `user_id` (default: `id`) | +| `sort` | string | no | Sort pipelines in `asc` or `desc` order (default: `desc`) | ``` curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/pipelines" diff --git a/doc/api/projects.md b/doc/api/projects.md index 51de4fef7ff..188fbe7447d 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -40,6 +40,7 @@ Parameters: | `owned` | boolean | no | Limit by projects owned by the current user | | `membership` | boolean | no | Limit by projects that the current user is a member of | | `starred` | boolean | no | Limit by projects starred by the current user | +| `statistics` | boolean | no | Include project statistics | ```json [ @@ -91,7 +92,14 @@ Parameters: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, - "request_access_enabled": false + "request_access_enabled": false, + "statistics": { + "commit_count": 37, + "storage_size": 1038090, + "repository_size": 1038090, + "lfs_objects_size": 0, + "job_artifacts_size": 0 + } }, { "id": 6, @@ -151,7 +159,14 @@ Parameters: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, - "request_access_enabled": false + "request_access_enabled": false, + "statistics": { + "commit_count": 12, + "storage_size": 2066080, + "repository_size": 2066080, + "lfs_objects_size": 0, + "job_artifacts_size": 0 + } } ] ``` @@ -170,6 +185,7 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `statistics` | boolean | no | Include project statistics | ```json { @@ -241,7 +257,14 @@ Parameters: ], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, - "request_access_enabled": false + "request_access_enabled": false, + "statistics": { + "commit_count": 37, + "storage_size": 1038090, + "repository_size": 1038090, + "lfs_objects_size": 0, + "job_artifacts_size": 0 + } } ``` diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png b/doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png Binary files differnew file mode 100644 index 00000000000..11ce324f938 --- /dev/null +++ b/doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif b/doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif Binary files differnew file mode 100644 index 00000000000..a6727a3d85f --- /dev/null +++ b/doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gif b/doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gif Binary files differnew file mode 100644 index 00000000000..36e6085259f --- /dev/null +++ b/doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gif diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md new file mode 100644 index 00000000000..1702c2184f2 --- /dev/null +++ b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md @@ -0,0 +1,266 @@ +# How to configure LDAP with GitLab CE + +> **Type:** admin guide || +> **Level:** intermediary || +> **Author:** [Chris Wilson](https://gitlab.com/MrChrisW) || +> **Publication date:** 2017/05/03 + +## Introduction + +Managing a large number of users in GitLab can become a burden for system administrators. As an organization grows so do user accounts. Keeping these user accounts in sync across multiple enterprise applications often becomes a time consuming task. + +In this guide we will focus on configuring GitLab with Active Directory. [Active Directory](https://en.wikipedia.org/wiki/Active_Directory) is a popular LDAP compatible directory service provided by Microsoft, included in all modern Windows Server operating systems. + +GitLab has supported LDAP integration since [version 2.2](https://about.gitlab.com/2012/02/22/gitlab-version-2-2/). With GitLab LDAP [group syncing](#group-syncing-ee) being added to GitLab Enterprise Edition in [version 6.0](https://about.gitlab.com/2013/08/20/gitlab-6-dot-0-released/). LDAP integration has become one of the most popular features in GitLab. + +## Getting started + +### Choosing an LDAP Server + +The main reason organizations choose to utilize a LDAP server is to keep the entire organization's user base consolidated into a central repository. Users can access multiple applications and systems across the IT environment using a single login. Because LDAP is an open, vendor-neutral, industry standard application protocol, the number of applications using LDAP authentication continues to increase. + +There are many commercial and open source [directory servers](https://en.wikipedia.org/wiki/Directory_service#LDAP_implementations) that support the LDAP protocol. Deciding on the right directory server highly depends on the existing IT environment in which the server will be integrated with. + +For example, [Active Directory](https://technet.microsoft.com/en-us/library/hh831484(v=ws.11).aspx) is generally favored in a primarily Windows environment, as this allows quick integration with existing services. Other popular directory services include: + +- [Oracle Internet Directory](http://www.oracle.com/technetwork/middleware/id-mgmt/overview/index-082035.html) +- [OpenLDAP](http://www.openldap.org/) +- [389 Directory](http://directory.fedoraproject.org/) +- [OpenDJ](https://forgerock.org/opendj/) +- [ApacheDS](https://directory.apache.org/) + +> GitLab uses the [Net::LDAP](https://rubygems.org/gems/net-ldap) library under the hood. This means it supports all [IETF](https://tools.ietf.org/html/rfc2251) compliant LDAPv3 servers. + +### Active Directory (AD) + +We won't cover the installation and configuration of Windows Server or Active Directory Domain Services in this tutorial. There are a number of resources online to guide you through this process: + +- Install Windows Server 2012 - (_technet.microsoft.com_) - [Installing Windows Server 2012 ](https://technet.microsoft.com/en-us/library/jj134246(v=ws.11).aspx) + +- Install Active Directory Domain Services (AD DS) (_technet.microsoft.com_)- [Install Active Directory Domain Services](https://technet.microsoft.com/windows-server-docs/identity/ad-ds/deploy/install-active-directory-domain-services--level-100-#BKMK_PS) + +> **Shortcut:** You can quickly install AD DS via PowerShell using +`Install-WindowsFeature AD-Domain-Services -IncludeManagementTools` + +### Creating an AD **OU** structure + +Configuring organizational units (**OU**s) is an important part of setting up Active Directory. **OU**s form the base for an entire organizational structure. Using GitLab as an example we have designed the **OU** structure below using the geographic **OU** model. In the Geographic Model we separate **OU**s for different geographic regions. + +| GitLab **OU** Design | GitLab AD Structure | +| :----------------------------: | :------------------------------: | +| ![GitLab OU Design][gitlab_ou] | ![GitLab AD Structure][ldap_ou] | + +[gitlab_ou]: img/gitlab_ou.png +[ldap_ou]: img/ldap_ou.gif + +Using PowerShell you can output the **OU** structure as a table (_all names are examples only_): + +```ps +Get-ADObject -LDAPFilter "(objectClass=*)" -SearchBase 'OU=GitLab INT,DC=GitLab,DC=org' -Properties CanonicalName | Format-Table Name,CanonicalName -A +``` + +``` +OU CanonicalName +---- ------------- +GitLab INT GitLab.org/GitLab INT +United States GitLab.org/GitLab INT/United States +Developers GitLab.org/GitLab INT/United States/Developers +Gary Johnson GitLab.org/GitLab INT/United States/Developers/Gary Johnson +Ellis Matthews GitLab.org/GitLab INT/United States/Developers/Ellis Matthews +William Collins GitLab.org/GitLab INT/United States/Developers/William Collins +People Ops GitLab.org/GitLab INT/United States/People Ops +Margaret Baker GitLab.org/GitLab INT/United States/People Ops/Margaret Baker +Libby Hartzler GitLab.org/GitLab INT/United States/People Ops/Libby Hartzler +Victoria Ryles GitLab.org/GitLab INT/United States/People Ops/Victoria Ryles +The Netherlands GitLab.org/GitLab INT/The Netherlands +Developers GitLab.org/GitLab INT/The Netherlands/Developers +John Doe GitLab.org/GitLab INT/The Netherlands/Developers/John Doe +Jon Mealy GitLab.org/GitLab INT/The Netherlands/Developers/Jon Mealy +Jane Weingarten GitLab.org/GitLab INT/The Netherlands/Developers/Jane Weingarten +Production GitLab.org/GitLab INT/The Netherlands/Production +Sarah Konopka GitLab.org/GitLab INT/The Netherlands/Production/Sarah Konopka +Cynthia Bruno GitLab.org/GitLab INT/The Netherlands/Production/Cynthia Bruno +David George GitLab.org/GitLab INT/The Netherlands/Production/David George +United Kingdom GitLab.org/GitLab INT/United Kingdom +Developers GitLab.org/GitLab INT/United Kingdom/Developers +Leroy Fox GitLab.org/GitLab INT/United Kingdom/Developers/Leroy Fox +Christopher Alley GitLab.org/GitLab INT/United Kingdom/Developers/Christopher Alley +Norris Morita GitLab.org/GitLab INT/United Kingdom/Developers/Norris Morita +Support GitLab.org/GitLab INT/United Kingdom/Support +Laura Stanley GitLab.org/GitLab INT/United Kingdom/Support/Laura Stanley +Nikki Schuman GitLab.org/GitLab INT/United Kingdom/Support/Nikki Schuman +Harriet Butcher GitLab.org/GitLab INT/United Kingdom/Support/Harriet Butcher +Global Groups GitLab.org/GitLab INT/Global Groups +DevelopersNL GitLab.org/GitLab INT/Global Groups/DevelopersNL +DevelopersUK GitLab.org/GitLab INT/Global Groups/DevelopersUK +DevelopersUS GitLab.org/GitLab INT/Global Groups/DevelopersUS +ProductionNL GitLab.org/GitLab INT/Global Groups/ProductionNL +SupportUK GitLab.org/GitLab INT/Global Groups/SupportUK +People Ops US GitLab.org/GitLab INT/Global Groups/People Ops US +Global Admins GitLab.org/GitLab INT/Global Groups/Global Admins +``` + +> See [more information](https://technet.microsoft.com/en-us/library/ff730967.aspx) on searching Active Directory with Windows PowerShell from [The Scripting Guys](https://technet.microsoft.com/en-us/scriptcenter/dd901334.aspx) + +## GitLab LDAP configuration + +The initial configuration of LDAP in GitLab requires changes to the `gitlab.rb` configuration file. Below is an example of a complete configuration using an Active Directory. + +The two Active Directory specific values are `active_directory: true` and `uid: 'sAMAccountName'`. `sAMAccountName` is an attribute returned by Active Directory used for GitLab usernames. See the example output from `ldapsearch` for a full list of attributes a "person" object (user) has in **AD** - [`ldapsearch` example](#using-ldapsearch-unix) + +> Both group_base and admin_group configuration options are only available in GitLab Enterprise Edition. See [GitLab EE - LDAP Features](#gitlab-enterprise-edition---ldap-features) + +### Example `gitlab.rb` LDAP + +``` +gitlab_rails['ldap_enabled'] = true +gitlab_rails['ldap_servers'] = { +'main' => { + 'label' => 'GitLab AD', + 'host' => 'ad.example.org', + 'port' => 636, + 'uid' => 'sAMAccountName', + 'method' => 'ssl', + 'bind_dn' => 'CN=GitLabSRV,CN=Users,DC=GitLab,DC=org', + 'password' => 'Password1', + 'active_directory' => true, + 'base' => 'OU=GitLab INT,DC=GitLab,DC=org', + 'group_base' => 'OU=Global Groups,OU=GitLab INT,DC=GitLab,DC=org', + 'admin_group' => 'Global Admins' + } +} +``` + +> **Note:** Remember to run `gitlab-ctl reconfigure` after modifying `gitlab.rb` + +## Security improvements (LDAPS) + +Security is an important aspect when deploying an LDAP server. By default, LDAP traffic is transmitted unsecured. LDAP can be secured using SSL/TLS called LDAPS, or commonly "LDAP over SSL". + +Securing LDAP (enabling LDAPS) on Windows Server 2012 involves installing a valid SSL certificate. For full details see Microsoft's guide [How to enable LDAP over SSL with a third-party certification authority](https://support.microsoft.com/en-us/help/321051/how-to-enable-ldap-over-ssl-with-a-third-party-certification-authority) + +> By default a LDAP service listens for connections on TCP and UDP port 389. LDAPS (LDAP over SSL) listens on port 636 + +### Testing you AD server + +#### Using **AdFind** (Windows) + +You can use the [`AdFind`](https://social.technet.microsoft.com/wiki/contents/articles/7535.adfind-command-examples.aspx) utility (on Windows based systems) to test that your LDAP server is accessible and authentication is working correctly. This is a freeware utility built by [Joe Richards](http://www.joeware.net/freetools/tools/adfind/index.htm). + +**Return all objects** + +You can use the filter `objectclass=*` to return all directory objects. + +```sh +adfind -h ad.example.org:636 -ssl -u "CN=GitLabSRV,CN=Users,DC=GitLab,DC=org" -up Password1 -b "OU=GitLab INT,DC=GitLab,DC=org" -f (objectClass=*) +``` + +**Return single object using filter** + +You can also retrieve a single object by **specifying** the object name or full **DN**. In this example we specify the object name only `CN=Leroy Fox`. + +```sh +adfind -h ad.example.org:636 -ssl -u "CN=GitLabSRV,CN=Users,DC=GitLab,DC=org" -up Password1 -b "OU=GitLab INT,DC=GitLab,DC=org" -f (&(objectcategory=person)(CN=Leroy Fox))” +``` + +#### Using **ldapsearch** (Unix) + +You can use the `ldapsearch` utility (on Unix based systems) to test that your LDAP server is accessible and authentication is working correctly. This utility is included in the [`ldap-utils`](https://wiki.debian.org/LDAP/LDAPUtils) package. + +**Return all objects** + +You can use the filter `objectclass=*` to return all directory objects. + +```sh +ldapsearch -D "CN=GitLabSRV,CN=Users,DC=GitLab,DC=org" \ +-w Password1 -p 636 -h ad.example.org \ +-b "OU=GitLab INT,DC=GitLab,DC=org" -Z \ +-s sub "(objectclass=*)" +``` + +**Return single object using filter** + +You can also retrieve a single object by **specifying** the object name or full **DN**. In this example we specify the object name only `CN=Leroy Fox`. + +```sh +ldapsearch -D "CN=GitLabSRV,CN=Users,DC=GitLab,DC=org" -w Password1 -p 389 -h ad.example.org -b "OU=GitLab INT,DC=GitLab,DC=org" -Z -s sub "CN=Leroy Fox" +``` + +**Full output of `ldapsearch` command:** - Filtering for _CN=Leroy Fox_ + +``` +# LDAPv3 +# base <OU=GitLab INT,DC=GitLab,DC=org> with scope subtree +# filter: CN=Leroy Fox +# requesting: ALL +# + +# Leroy Fox, Developers, United Kingdom, GitLab INT, GitLab.org +dn: CN=Leroy Fox,OU=Developers,OU=United Kingdom,OU=GitLab INT,DC=GitLab,DC=or + g +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: user +cn: Leroy Fox +sn: Fox +givenName: Leroy +distinguishedName: CN=Leroy Fox,OU=Developers,OU=United Kingdom,OU=GitLab INT, + DC=GitLab,DC=org +instanceType: 4 +whenCreated: 20170210030500.0Z +whenChanged: 20170213050128.0Z +displayName: Leroy Fox +uSNCreated: 16790 +memberOf: CN=DevelopersUK,OU=Global Groups,OU=GitLab INT,DC=GitLab,DC=org +uSNChanged: 20812 +name: Leroy Fox +objectGUID:: rBCAo6NR6E6vfSKgzcUILg== +userAccountControl: 512 +badPwdCount: 0 +codePage: 0 +countryCode: 0 +badPasswordTime: 0 +lastLogoff: 0 +lastLogon: 0 +pwdLastSet: 131311695009850084 +primaryGroupID: 513 +objectSid:: AQUAAAAAAAUVAAAA9GMAb7tdJZvsATf7ZwQAAA== +accountExpires: 9223372036854775807 +logonCount: 0 +sAMAccountName: Leroyf +sAMAccountType: 805306368 +userPrincipalName: Leroyf@GitLab.org +objectCategory: CN=Person,CN=Schema,CN=Configuration,DC=GitLab,DC=org +dSCorePropagationData: 16010101000000.0Z +lastLogonTimestamp: 131314356887754250 + +# search result +search: 2 +result: 0 Success + +# numResponses: 2 +# numEntries: 1 +``` + +## Basic user authentication + +After configuring LDAP, basic authentication will be available. Users can then login using their directory credentials. An extra tab is added to the GitLab login screen for the configured LDAP server (e.g "**GitLab AD**"). + +![GitLab OU Structure](img/user_auth.gif) + +Users that are removed from the LDAP base group (e.g `OU=GitLab INT,DC=GitLab,DC=org`) will be **blocked** in GitLab. [More information](../../administration/auth/ldap.md#security) on LDAP security. + +If `allow_username_or_email_login` is enabled in the LDAP configuration, GitLab will ignore everything after the first '@' in the LDAP username used on login. Example: The username `jon.doe@example.com` is converted to `jon.doe` when authenticating with the LDAP server. Disable this setting if you use `userPrincipalName` as the `uid`. + +## LDAP extended features on GitLab EE + +With [GitLab Enterprise Edition (EE)](https://about.gitlab.com/giltab-ee/), besides everything we just described, you'll +have extended functionalities with LDAP, such as: + +- Group sync +- Group permissions +- Updating user permissions +- Multiple LDAP servers + +Read through the article on [LDAP for GitLab EE](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/) for an overview. diff --git a/doc/articles/index.md b/doc/articles/index.md index 67eab36bf2c..49db64134f5 100644 --- a/doc/articles/index.md +++ b/doc/articles/index.md @@ -7,6 +7,11 @@ to provide the community with guidance on specific processes to achieve certain They are written by members of the GitLab Team and by [Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/). +## Authentication + +- **LDAP** + - [How to configure LDAP with GitLab CE](how_to_configure_ldap_gitlab_ce/index.md) + ## GitLab Pages - **GitLab Pages from A to Z** diff --git a/doc/ci/img/pipelines_grouped.png b/doc/ci/img/pipelines_grouped.png Binary files differnew file mode 100644 index 00000000000..06f52e03320 --- /dev/null +++ b/doc/ci/img/pipelines_grouped.png diff --git a/doc/ci/img/pipelines_index.png b/doc/ci/img/pipelines_index.png Binary files differnew file mode 100644 index 00000000000..3b522a9c5e4 --- /dev/null +++ b/doc/ci/img/pipelines_index.png diff --git a/doc/ci/img/pipelines_mini_graph.png b/doc/ci/img/pipelines_mini_graph.png Binary files differnew file mode 100644 index 00000000000..042c8ffeef5 --- /dev/null +++ b/doc/ci/img/pipelines_mini_graph.png diff --git a/doc/ci/img/pipelines_mini_graph_simple.png b/doc/ci/img/pipelines_mini_graph_simple.png Binary files differnew file mode 100644 index 00000000000..eb36c09b2d4 --- /dev/null +++ b/doc/ci/img/pipelines_mini_graph_simple.png diff --git a/doc/ci/img/pipelines_mini_graph_sorting.png b/doc/ci/img/pipelines_mini_graph_sorting.png Binary files differnew file mode 100644 index 00000000000..3a4e5453360 --- /dev/null +++ b/doc/ci/img/pipelines_mini_graph_sorting.png diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md index db92a4b0d80..5a2b61fb0cb 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -1,7 +1,6 @@ # Introduction to pipelines and jobs ->**Note:** -Introduced in GitLab 8.8. +> Introduced in GitLab 8.8. ## Pipelines @@ -9,11 +8,17 @@ A pipeline is a group of [jobs][] that get executed in [stages][](batches). All of the jobs in a stage are executed in parallel (if there are enough concurrent [Runners]), and if they all succeed, the pipeline moves on to the next stage. If one of the jobs fails, the next stage is not (usually) -executed. +executed. You can access the pipelines page in your project's **Pipelines** tab. + +In the following image you can see that the pipeline consists of four stages +(`build`, `test`, `staging`, `production`) each one having one or more jobs. + +>**Note:** +GitLab capitalizes the stages' names when shown in the [pipeline graphs](#pipeline-graphs). ![Pipelines example](img/pipelines.png) -## Types of Pipelines +## Types of pipelines There are three types of pipelines that often use the single shorthand of "pipeline". People often talk about them as if each one is "the" pipeline, but really, they're just pieces of a single, comprehensive pipeline. @@ -23,7 +28,7 @@ There are three types of pipelines that often use the single shorthand of "pipel 2. **Deploy Pipeline**: Deploy stage(s) defined in `.gitlab-ci.yml` The flow of deploying code to servers through various stages: e.g. development to staging to production 3. **Project Pipeline**: Cross-project CI dependencies [triggered via API][triggers], particularly for micro-services, but also for complicated build dependencies: e.g. api -> front-end, ce/ee -> omnibus. -## Development Workflows +## Development workflows Pipelines accommodate several development workflows: @@ -45,18 +50,141 @@ confused with a `build` job or `build` stage. Pipelines are defined in `.gitlab-ci.yml` by specifying [jobs] that run in [stages]. -See full [documentation](yaml/README.md#jobs). +See the reference [documentation for jobs](yaml/README.md#jobs). ## Seeing pipeline status -You can find the current and historical pipeline runs under **Pipelines** for -your project. +You can find the current and historical pipeline runs under your project's +**Pipelines** tab. Clicking on a pipeline will show the jobs that were run for +that pipeline. + +![Pipelines index page](img/pipelines_index.png) ## Seeing job status -Clicking on a pipeline will show the jobs that were run for that pipeline. +When you visit a single pipeline you can see the related jobs for that pipeline. Clicking on an individual job will show you its job trace, and allow you to -cancel the job, retry it, or erase the job trace. +cancel the job, retry it, or erase the job trace. + +![Pipelines example](img/pipelines.png) + +## Pipeline graphs + +> [Introduced][ce-5742] in GitLab 8.11. + +Pipelines can be complex structures with many sequential and parallel jobs. +To make it a little easier to see what is going on, you can view a graph +of a single pipeline and its status. + +A pipeline graph can be shown in two different ways depending on what page you +are on. + +--- + +The regular pipeline graph that shows the names of the jobs of each stage can +be found when you are on a [single pipeline page](#seeing-pipeline-status). + +![Pipelines example](img/pipelines.png) + +Then, there is the pipeline mini graph which takes less space and can give you a +quick glance if all jobs pass or something failed. The pipeline mini graph can +be found when you visit: + +- the pipelines index page +- a single commit page +- a merge request page + +That way, you can see all related jobs for a single commit and the net result +of each stage of your pipeline. This allows you to quickly see what failed and +fix it. Stages in pipeline mini graphs are collapsible. Hover your mouse over +them and click to expand their jobs. + +| **Mini graph** | **Mini graph expanded** | +| :------------: | :---------------------: | +| ![Pipelines mini graph](img/pipelines_mini_graph_simple.png) | ![Pipelines mini graph extended](img/pipelines_mini_graph.png) | + +### Grouping similar jobs in the pipeline graph + +> [Introduced][ce-6242] in GitLab 8.12. + +If you have many similar jobs, your pipeline graph becomes very long and hard +to read. For that reason, similar jobs can automatically be grouped together. +If the job names are formatted in certain ways, they will be collapsed into +a single group in regular pipeline graphs (not the mini graphs). +You'll know when a pipeline has grouped jobs if you don't see the retry or +cancel button inside them. Hovering over them will show the number of grouped +jobs. Click to expand them. + +![Grouped pipelines](img/pipelines_grouped.png) + +The basic requirements is that there are two numbers separated with one of +the following (you can even use them interchangeably): + +- a space +- a backslash (`/`) +- a colon (`:`) + +>**Note:** +More specifically, [it uses][regexp] this regular expression: `\d+[\s:\/\\]+\d+\s*`. + +The jobs will be ordered by comparing those two numbers from left to right. You +usually want the first to be the index and the second the total. + +For example, the following jobs will be grouped under a job named `test`: + +- `test 0 3` => `test` +- `test 1 3` => `test` +- `test 2 3` => `test` + +The following jobs will be grouped under a job named `test ruby`: + +- `test 1:2 ruby` => `test ruby` +- `test 2:2 ruby` => `test ruby` + +The following jobs will be grouped under a job named `test ruby` as well: + +- `1/3 test ruby` => `test ruby` +- `2/3 test ruby` => `test ruby` +- `3/3 test ruby` => `test ruby` + +### Manual actions from the pipeline graph + +> [Introduced][ce-7931] in GitLab 8.15. + +[Manual actions][manual] allow you to require manual interaction before moving +forward with a particular job in CI. Your entire pipeline can run automatically, +but the actual [deploy to production][env-manual] will require a click. + +You can do this straight from the pipeline graph. Just click on the play button +to execute that particular job. For example, in the image below, the `production` +stage has a job with a manual action. + +![Pipelines example](img/pipelines.png) + +### Ordering of jobs in pipeline graphs + +**Regular pipeline graph** + +In the single pipeline page, jobs are sorted by name. + +**Mini pipeline graph** + +> [Introduced][ce-9760] in GitLab 9.0. + +In the pipeline mini graphs, the jobs are sorted first by severity and then +by name. The order of severity is: + +- failed +- warning +- pending +- running +- manual +- canceled +- success +- skipped +- created + +![Pipeline mini graph sorting](img/pipelines_mini_graph_sorting.png) ## How the pipeline duration is calculated @@ -96,7 +224,14 @@ respective link in the [Pipelines settings] page. [jobs]: #jobs [jobs-yaml]: yaml/README.md#jobs +[manual]: yaml/README.md#manual +[env-manual]: environments.md#manually-deploying-to-environments [stages]: yaml/README.md#stages [runners]: runners/README.html [pipelines settings]: ../user/project/pipelines/settings.md [triggers]: triggers/README.md +[ce-5742]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5742 +[ce-6242]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6242 +[ce-7931]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7931 +[ce-9760]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9760 +[regexp]: https://gitlab.com/gitlab-org/gitlab-ce/blob/2f3dc314f42dbd79813e6251792853bc231e69dd/app/models/commit_status.rb#L99 diff --git a/doc/development/README.md b/doc/development/README.md index 77bb0263374..d04380e5b33 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -41,6 +41,7 @@ - [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) - [Object state models](object_state_models.md) +- [Building a package for testing purposes](build_test_package.md) ## Databases diff --git a/doc/development/build_test_package.md b/doc/development/build_test_package.md new file mode 100644 index 00000000000..2bc1a700844 --- /dev/null +++ b/doc/development/build_test_package.md @@ -0,0 +1,35 @@ +# Building a package for testing + +While developing a new feature or modifying an existing one, it is helpful if an +installable package (or a docker image) containing those changes is available +for testing. For this very purpose, a manual job is provided in the GitLab CI/CD +pipeline that can be used to trigger a pipeline in the omnibus-gitlab repository +that will create +1. A deb package for Ubuntu 16.04, available as a build artifact, and +2. A docker image, which is pushed to [Omnibus GitLab's container +registry](https://gitlab.com/gitlab-org/omnibus-gitlab/container_registry) +(images titled `gitlab-ce` and `gitlab-ee` respectively and image tag is the +commit which triggered the pipeline). + +When you push a commit to either the gitlab-ce or gitlab-ee project, the +pipeline for that commit will have a `build-package` manual action you can +trigger. + +## Specifying versions of components + +If you want to create a package from a specific branch, commit or tag of any of +the GitLab components (like GitLab Workhorse, Gitaly, GitLab Pages, etc.), you +can specify the branch name, commit sha or tag in the component's respective +`*_VERSION` file. For example, if you want to build a package that uses the +branch `0-1-stable`, modify the content of `GITALY_SERVER_VERSION` to +`0-1-stable` and push the commit. This will create a manual job that can be +used to trigger the build. + +## Specifying the branch in omnibus-gitlab repository + +In scenarios where a configuration change is to be introduced and omnibus-gitlab +repository already has the necessary changes in a specific branch, you can build +a package against that branch through an environment variable named +`OMNIBUS_BRANCH`. To do this, specify that environment variable with the name of +the branch as value in `.gitlab-ci.yml` and push a commit. This will create a +manual job that can be used to trigger the build. diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 819578404b6..be3dd1e2cc6 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -1,5 +1,25 @@ # Code Review Guidelines +## Getting your merge request reviewed, approved, and merged + +There are a few rules to get your merge request accepted: + +1. Your merge request should only be **merged by a [maintainer][team]**. + 1. If your merge request includes only backend changes [^1], it must be + **approved by a [backend maintainer][team]**. + 1. If your merge request includes only frontend changes [^1], it must be + **approved by a [frontend maintainer][team]**. + 1. If your merge request includes frontend and backend changes [^1], it must + be **approved by a [frontend and a backend maintainer][team]**. +1. To lower the amount of merge requests maintainers need to review, you can + ask or assign any [reviewers][team] for a first review. + 1. If you need some guidance (e.g. it's your first merge request), feel free + to ask one of the [Merge request coaches][team]. + 1. The reviewer will assign the merge request to a maintainer once the + reviewer is satisfied with the state of the merge request. + +## Best practices + This guide contains advice and best practices for performing code review, and having your code reviewed. @@ -12,7 +32,7 @@ of colleagues and contributors. However, the final decision to accept a merge request is up to one the project's maintainers, denoted on the [team page](https://about.gitlab.com/team). -## Everyone +### Everyone - Accept that many programming decisions are opinions. Discuss tradeoffs, which you prefer, and reach a resolution quickly. @@ -31,8 +51,11 @@ request is up to one the project's maintainers, denoted on the - Consider one-on-one chats or video calls if there are too many "I didn't understand" or "Alternative solution:" comments. Post a follow-up comment summarizing one-on-one discussion. +- If you ask a question to a specific person, always start the comment by + mentioning them; this will ensure they see it if their notification level is + set to "mentioned" and other people will understand they don't have to respond. -## Having your code reviewed +### Having your code reviewed Please keep in mind that code review is a process that can take multiple iterations, and reviewers may spot things later that they may not have seen the @@ -50,11 +73,12 @@ first time. - Extract unrelated changes and refactorings into future merge requests/issues. - Seek to understand the reviewer's perspective. - Try to respond to every comment. +- Let the reviewer select the "Resolve discussion" buttons. - Push commits based on earlier rounds of feedback as isolated commits to the branch. Do not squash until the branch is ready to merge. Reviewers should be able to read individual updates based on their earlier feedback. -## Reviewing code +### Reviewing code Understand why the change is necessary (fixes a bug, improves the user experience, refactors the existing code). Then: @@ -69,12 +93,19 @@ experience, refactors the existing code). Then: someone else would be confused by it as well. - After a round of line notes, it can be helpful to post a summary note such as "LGTM :thumbsup:", or "Just a couple things to address." +- Assign the merge request to the author if changes are required following your + review. +- Set the milestone before merging a merge request. - Avoid accepting a merge request before the job succeeds. Of course, "Merge When Pipeline Succeeds" (MWPS) is fine. - If you set the MR to "Merge When Pipeline Succeeds", you should take over subsequent revisions for anything that would be spotted after that. +- Consider using the [Squash and + merge][squash-and-merge] feature when the merge request has a lot of commits. + +[squash-and-merge]: https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#squash-and-merge -## The right balance +### The right balance One of the most difficult things during code review is finding the right balance in how deep the reviewer can interfere with the code created by a @@ -100,7 +131,7 @@ reviewee. tomorrow. When you are not able to find the right balance, ask other people about their opinion. -## Credits +### Credits Largely based on the [thoughtbot code review guide]. diff --git a/doc/development/fe_guide/droplab/droplab.md b/doc/development/fe_guide/droplab/droplab.md index 8f0b6b21953..112ff3419d9 100644 --- a/doc/development/fe_guide/droplab/droplab.md +++ b/doc/development/fe_guide/droplab/droplab.md @@ -183,6 +183,8 @@ For example, either by a mouse click or by enter key selection. * The `droplab-item-active` css class is added to items that have been selected using arrow key navigation. +* You can add the `droplab-item-ignore` css class to any item that you do not want to be selectable. For example, +an `<li class="divider"></li>` list divider element that should not be interactive. ## Internal events diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index 1d2b0558948..d2d89517241 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -11,207 +11,205 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. #### ESlint -- **Never** disable eslint rules unless you have a good reason. You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` at the top, but legacy files are a special case. Any time you develop a new feature or refactor an existing one, you should abide by the eslint rules. - -- **Never Ever EVER** disable eslint globally for a file +1. **Never** disable eslint rules unless you have a good reason. +You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` +at the top, but legacy files are a special case. Any time you develop a new feature or +refactor an existing one, you should abide by the eslint rules. +1. **Never Ever EVER** disable eslint globally for a file ```javascript - // bad - /* eslint-disable */ + // bad + /* eslint-disable */ - // better - /* eslint-disable some-rule, some-other-rule */ + // better + /* eslint-disable some-rule, some-other-rule */ - // best - // nothing :) + // best + // nothing :) ``` -- If you do need to disable a rule for a single violation, try to do it as locally as possible - +1. If you do need to disable a rule for a single violation, try to do it as locally as possible ```javascript - // bad - /* eslint-disable no-new */ + // bad + /* eslint-disable no-new */ - import Foo from 'foo'; + import Foo from 'foo'; - new Foo(); + new Foo(); - // better - import Foo from 'foo'; + // better + import Foo from 'foo'; - // eslint-disable-next-line no-new - new Foo(); + // eslint-disable-next-line no-new + new Foo(); ``` +1. There are few rules that we need to disable due to technical debt. Which are: + 1. [no-new][eslint-new] + 1. [class-methods-use-this][eslint-this] -- When they are needed _always_ place ESlint directive comment blocks on the first line of a script, followed by any global declarations, then a blank newline prior to any imports or code. - +1. When they are needed _always_ place ESlint directive comment blocks on the first line of a script, +followed by any global declarations, then a blank newline prior to any imports or code. ```javascript - // bad - /* global Foo */ - /* eslint-disable no-new */ - import Bar from './bar'; + // bad + /* global Foo */ + /* eslint-disable no-new */ + import Bar from './bar'; - // good - /* eslint-disable no-new */ - /* global Foo */ + // good + /* eslint-disable no-new */ + /* global Foo */ - import Bar from './bar'; + import Bar from './bar'; ``` -- **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. - -- When declaring multiple globals, always use one `/* global [name] */` line per variable. +1. **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. +1. When declaring multiple globals, always use one `/* global [name] */` line per variable. ```javascript - // bad - /* globals Flash, Cookies, jQuery */ + // bad + /* globals Flash, Cookies, jQuery */ - // good - /* global Flash */ - /* global Cookies */ - /* global jQuery */ + // good + /* global Flash */ + /* global Cookies */ + /* global jQuery */ ``` - -- Use up to 3 parameters for a function or class. If you need more accept an Object instead. +1. Use up to 3 parameters for a function or class. If you need more accept an Object instead. ```javascript - // bad - fn(p1, p2, p3, p4) {} + // bad + fn(p1, p2, p3, p4) {} - // good - fn(options) {} + // good + fn(options) {} ``` #### Modules, Imports, and Exports -- Use ES module syntax to import modules - +1. Use ES module syntax to import modules ```javascript - // bad - require('foo'); + // bad + require('foo'); - // good - import Foo from 'foo'; + // good + import Foo from 'foo'; - // bad - module.exports = Foo; + // bad + module.exports = Foo; - // good - export default Foo; + // good + export default Foo; ``` -- Relative paths - - Unless you are writing a test, always reference other scripts using relative paths instead of `~` +1. Relative paths: Unless you are writing a test, always reference other scripts using +relative paths instead of `~` + * In **app/assets/javascripts**: - In **app/assets/javascripts**: - ```javascript - // bad - import Foo from '~/foo' - - // good - import Foo from '../foo'; - ``` + ```javascript + // bad + import Foo from '~/foo' - In **spec/javascripts**: - ```javascript - // bad - import Foo from '../../app/assets/javascripts/foo' + // good + import Foo from '../foo'; + ``` + * In **spec/javascripts**: - // good - import Foo from '~/foo'; - ``` + ```javascript + // bad + import Foo from '../../app/assets/javascripts/foo' -- Avoid using IIFE. Although we have a lot of examples of files which wrap their contents in IIFEs (immediately-invoked function expressions), this is no longer necessary after the transition from Sprockets to webpack. Do not use them anymore and feel free to remove them when refactoring legacy code. + // good + import Foo from '~/foo'; + ``` -- Avoid adding to the global namespace. +1. Avoid using IIFE. Although we have a lot of examples of files which wrap their +contents in IIFEs (immediately-invoked function expressions), +this is no longer necessary after the transition from Sprockets to webpack. +Do not use them anymore and feel free to remove them when refactoring legacy code. +1. Avoid adding to the global namespace. ```javascript - // bad - window.MyClass = class { /* ... */ }; + // bad + window.MyClass = class { /* ... */ }; - // good - export default class MyClass { /* ... */ } + // good + export default class MyClass { /* ... */ } ``` -- Side effects are forbidden in any script which contains exports - +1. Side effects are forbidden in any script which contains exports ```javascript - // bad - export default class MyClass { /* ... */ } + // bad + export default class MyClass { /* ... */ } - document.addEventListener("DOMContentLoaded", function(event) { - new MyClass(); - } + document.addEventListener("DOMContentLoaded", function(event) { + new MyClass(); + } ``` #### Data Mutation and Pure functions -- Strive to write many small pure functions, and minimize where mutations occur. - +1. Strive to write many small pure functions, and minimize where mutations occur. ```javascript - // bad - const values = {foo: 1}; + // bad + const values = {foo: 1}; - function impureFunction(items) { - const bar = 1; + function impureFunction(items) { + const bar = 1; - items.foo = items.a * bar + 2; + items.foo = items.a * bar + 2; - return items.a; - } + return items.a; + } - const c = impureFunction(values); + const c = impureFunction(values); - // good - var values = {foo: 1}; + // good + var values = {foo: 1}; - function pureFunction (foo) { - var bar = 1; + function pureFunction (foo) { + var bar = 1; - foo = foo * bar + 2; + foo = foo * bar + 2; - return foo; - } + return foo; + } - var c = pureFunction(values.foo); + var c = pureFunction(values.foo); ``` -- Avoid constructors with side-effects +1. Avoid constructors with side-effects -- Prefer `.map`, `.reduce` or `.filter` over `.forEach` +1. Prefer `.map`, `.reduce` or `.filter` over `.forEach` A forEach will cause side effects, it will be mutating the array being iterated. Prefer using `.map`, `.reduce` or `.filter` - ```javascript - const users = [ { name: 'Foo' }, { name: 'Bar' } ]; + const users = [ { name: 'Foo' }, { name: 'Bar' } ]; - // bad - users.forEach((user, index) => { - user.id = index; - }); + // bad + users.forEach((user, index) => { + user.id = index; + }); - // good - const usersWithId = users.map((user, index) => { - return Object.assign({}, user, { id: index }); - }); + // good + const usersWithId = users.map((user, index) => { + return Object.assign({}, user, { id: index }); + }); ``` #### Parse Strings into Numbers -- `parseInt()` is preferable over `Number()` or `+` - +1. `parseInt()` is preferable over `Number()` or `+` ```javascript - // bad - +'10' // 10 + // bad + +'10' // 10 - // good - Number('10') // 10 + // good + Number('10') // 10 - // better - parseInt('10', 10); + // better + parseInt('10', 10); ``` #### CSS classes used for JavaScript -- If the class is being used in Javascript it needs to be prepend with `js-` +1. If the class is being used in Javascript it needs to be prepend with `js-` ```html // bad <button class="add-user"> @@ -226,234 +224,270 @@ A forEach will cause side effects, it will be mutating the array being iterated. ### Vue.js - #### Basic Rules -- Only include one Vue.js component per file. -- Export components as plain objects: - +1. The service has it's own file +1. The store has it's own file +1. Use a function in the bundle file to instantiate the Vue component: ```javascript - export default { - template: `<h1>I'm a component</h1> - } - ``` + // bad + class { + init() { + new Component({}) + } + } -#### Naming -- **Extensions**: Use `.vue` extension for Vue components. -- **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances: + // good + document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#element', + components: { + componentName + }, + render: createElement => createElement('component-name'), + })); + ``` +1. Don not use a singleton for the service or the store ```javascript - // bad - import cardBoard from 'cardBoard'; + // bad + class Store { + constructor() { + if (!this.prototype.singleton) { + // do something + } + } + } - // good - import CardBoard from 'cardBoard' + // good + class Store { + constructor() { + // do something + } + } + ``` - // bad - components: { - CardBoard: CardBoard - }; +#### Naming +1. **Extensions**: Use `.vue` extension for Vue components. +1. **Reference Naming**: Use camelCase for their instances: + ```javascript + // good + import cardBoard from 'cardBoard' - // good - components: { - cardBoard: CardBoard - }; + components: { + cardBoard: + }; ``` -- **Props Naming:** -- Avoid using DOM component prop names. -- Use kebab-case instead of camelCase to provide props in templates. - +1. **Props Naming:** Avoid using DOM component prop names. +1. **Props Naming:** Use kebab-case instead of camelCase to provide props in templates. ```javascript - // bad - <component class="btn"> + // bad + <component class="btn"> - // good - <component css-class="btn"> + // good + <component css-class="btn"> - // bad - <component myProp="prop" /> + // bad + <component myProp="prop" /> - // good - <component my-prop="prop" /> -``` + // good + <component my-prop="prop" /> + ``` #### Alignment -- Follow these alignment styles for the template method: - +1. Follow these alignment styles for the template method: ```javascript - // bad - <component v-if="bar" - param="baz" /> + // bad + <component v-if="bar" + param="baz" /> - <button class="btn">Click me</button> + <button class="btn">Click me</button> - // good - <component - v-if="bar" - param="baz" - /> + // good + <component + v-if="bar" + param="baz" + /> - <button class="btn"> - Click me - </button> + <button class="btn"> + Click me + </button> - // if props fit in one line then keep it on the same line - <component bar="bar" /> + // if props fit in one line then keep it on the same line + <component bar="bar" /> ``` #### Quotes -- Always use double quotes `"` inside templates and single quotes `'` for all other JS. - +1. Always use double quotes `"` inside templates and single quotes `'` for all other JS. ```javascript - // bad - template: ` - <button :class='style'>Button</button> - ` - - // good - template: ` - <button :class="style">Button</button> - ` + // bad + template: ` + <button :class='style'>Button</button> + ` + + // good + template: ` + <button :class="style">Button</button> + ` ``` #### Props -- Props should be declared as an object - +1. Props should be declared as an object ```javascript - // bad - props: ['foo'] - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + // bad + props: ['foo'] + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } ``` -- Required key should always be provided when declaring a prop - +1. Required key should always be provided when declaring a prop ```javascript - // bad - props: { - foo: { - type: String, + // bad + props: { + foo: { + type: String, + } } - } - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } ``` -- Default key should always be provided if the prop is not required: - +1. Default key should always be provided if the prop is not required: ```javascript - // bad - props: { - foo: { - type: String, - required: false, + // bad + props: { + foo: { + type: String, + required: false, + } } - } - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } - // good - props: { - foo: { - type: String, - required: true + // good + props: { + foo: { + type: String, + required: true + } } - } ``` #### Data -- `data` method should always be a function +1. `data` method should always be a function ```javascript - // bad - data: { - foo: 'foo' - } - - // good - data() { - return { + // bad + data: { foo: 'foo' - }; - } + } + + // good + data() { + return { + foo: 'foo' + }; + } ``` #### Directives -- Shorthand `@` is preferable over `v-on` - +1. Shorthand `@` is preferable over `v-on` ```javascript - // bad - <component v-on:click="eventHandler"/> + // bad + <component v-on:click="eventHandler"/> - // good - <component @click="eventHandler"/> + // good + <component @click="eventHandler"/> ``` -- Shorthand `:` is preferable over `v-bind` - +1. Shorthand `:` is preferable over `v-bind` ```javascript - // bad - <component v-bind:class="btn"/> + // bad + <component v-bind:class="btn"/> - // good - <component :class="btn"/> + // good + <component :class="btn"/> ``` #### Closing tags -- Prefer self closing component tags - +1. Prefer self closing component tags ```javascript - // bad - <component></component> + // bad + <component></component> - // good - <component /> + // good + <component /> ``` #### Ordering -- Order for a Vue Component: +1. Order for a Vue Component: 1. `name` - 2. `props` - 3. `data` - 4. `components` - 5. `computedProps` - 6. `methods` - 7. lifecycle methods - 1. `beforeCreate` - 2. `created` - 3. `beforeMount` - 4. `mounted` - 5. `beforeUpdate` - 6. `updated` - 7. `activated` - 8. `deactivated` - 9. `beforeDestroy` - 10. `destroyed` - 8. `template` + 1. `props` + 1. `mixins` + 1. `data` + 1. `components` + 1. `computedProps` + 1. `methods` + 1. `beforeCreate` + 1. `created` + 1. `beforeMount` + 1. `mounted` + 1. `beforeUpdate` + 1. `updated` + 1. `activated` + 1. `deactivated` + 1. `beforeDestroy` + 1. `destroyed` + +#### Vue and Boostrap +1. Tooltips: Do not rely on `has-tooltip` class name for vue components + ```javascript + // bad + <span class="has-tooltip"> + Text + </span> + + // good + <span data-toggle="tooltip"> + Text + </span> + ``` + +1. Tooltips: When using a tooltip, include the tooltip mixin + +1. Don't change `data-original-title`. + ```javascript + // bad + <span data-original-title="tooltip text">Foo</span> + + // good + <span title="tooltip text">Foo</span> + + $('span').tooltip('fixTitle'); + ``` ## SCSS @@ -461,3 +495,5 @@ A forEach will cause side effects, it will be mutating the array being iterated. [airbnb-js-style-guide]: https://github.com/airbnb/javascript [eslintrc]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.eslintrc +[eslint-this]: http://eslint.org/docs/rules/class-methods-use-this +[eslint-new]: http://eslint.org/docs/rules/no-new diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md index 73d2ffc1bdc..a984bb6c94c 100644 --- a/doc/development/fe_guide/vue.md +++ b/doc/development/fe_guide/vue.md @@ -387,6 +387,10 @@ describe('Todos App', () => { }); }); ``` +#### Test the component's output +The main return value of a Vue component is the rendered output. In order to test the component we +need to test the rendered output. [Vue][vue-test] guide's to unit test show us exactly that: + ### Stubbing API responses [Vue Resource Interceptors][vue-resource-interceptor] allow us to add a interceptor with @@ -419,6 +423,16 @@ the response we need: }); ``` +1. Use `$.mount()` to mount the component +```javascript + // bad + new Component({ + el: document.createElement('div') + }); + + // good + new Component().$mount(); +``` [vue-docs]: http://vuejs.org/guide/index.html [issue-boards]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/boards @@ -429,5 +443,6 @@ the response we need: [one-way-data-flow]: https://vuejs.org/v2/guide/components.html#One-Way-Data-Flow [vue-resource-repo]: https://github.com/pagekit/vue-resource [vue-resource-interceptor]: https://github.com/pagekit/vue-resource/blob/develop/docs/http.md#interceptors +[vue-test]: https://vuejs.org/v2/guide/unit-testing.html [issue-boards-service]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/boards/services/board_service.js.es6 [flux]: https://facebook.github.io/flux diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 3e8b709c18f..77ba2a5fd87 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -270,3 +270,28 @@ end When doing so be sure to explicitly set the model's table name so it's not derived from the class name or namespace. + +### Renaming reserved paths + +When a new route for projects is introduced that could conflict with any +existing records. The path for this records should be renamed, and the +related data should be moved on disk. + +Since we had to do this a few times already, there are now some helpers to help +with this. + +To use this you can include `Gitlab::Database::RenameReservedPathsMigration::V1` +in your migration. This will provide 3 methods which you can pass one or more +paths that need to be rejected. + +**`rename_root_paths`**: This will rename the path of all _namespaces_ with the +given name that don't have a `parent_id`. + +**`rename_child_paths`**: This will rename the path of all _namespaces_ with the +given name that have a `parent_id`. + +**`rename_wildcard_paths`**: This will rename the path of all _projects_, and all +_namespaces_ that have a `project_id`. + +The `path` column for these rows will be renamed to their previous value followed +by an integer. For example: `users` would turn into `users0` diff --git a/doc/development/rake_tasks.md b/doc/development/rake_tasks.md index ec9e4dcc59d..fdaaa65fa28 100644 --- a/doc/development/rake_tasks.md +++ b/doc/development/rake_tasks.md @@ -31,16 +31,26 @@ files it can find, also the ones in `/tmp` To run a single test file you can use: -- `bundle exec rspec spec/controllers/commit_controller_spec.rb` for a rspec test -- `bundle exec spinach features/project/issues/milestones.feature` for a spinach test +- `bin/rspec spec/controllers/commit_controller_spec.rb` for a rspec test +- `bin/spinach features/project/issues/milestones.feature` for a spinach test To run several tests inside one directory: -- `bundle exec rspec spec/requests/api/` for the rspec tests if you want to test API only -- `bundle exec spinach features/profile/` for the spinach tests if you want to test only profile pages +- `bin/rspec spec/requests/api/` for the rspec tests if you want to test API only +- `bin/spinach features/profile/` for the spinach tests if you want to test only profile pages -If you want to use [Spring](https://github.com/rails/spring) set -`ENABLE_SPRING=1` in your environment. +### Speed-up tests, rake tasks, and migrations + +[Spring](https://github.com/rails/spring) is a Rails application preloader. It +speeds up development by keeping your application running in the background so +you don't need to boot it every time you run a test, rake task or migration. + +If you want to use it, you'll need to export the `ENABLE_SPRING` environment +variable to `1`: + +``` +export ENABLE_SPRING=1 +``` ## Compile Frontend Assets diff --git a/doc/development/testing.md b/doc/development/testing.md index 9b0b9808827..6d8b846d27f 100644 --- a/doc/development/testing.md +++ b/doc/development/testing.md @@ -188,7 +188,8 @@ Please consult the [dedicated "Frontend testing" guide](./fe_guide/testing.md). ### General Guidelines - Use a single, top-level `describe ClassName` block. -- Use `described_class` instead of repeating the class name being described. +- Use `described_class` instead of repeating the class name being described + (_this is enforced by RuboCop_). - Use `.method` to describe class methods and `#method` to describe instance methods. - Use `context` to test branching logic. @@ -197,7 +198,7 @@ Please consult the [dedicated "Frontend testing" guide](./fe_guide/testing.md). - Don't `describe` symbols (see [Gotchas](gotchas.md#dont-describe-symbols)). - Don't assert against the absolute value of a sequence-generated attribute (see [Gotchas](gotchas.md#dont-assert-against-the-absolute-value-of-a-sequence-generated-attribute)). - Don't supply the `:each` argument to hooks since it's the default. -- Prefer `not_to` to `to_not` (_this is enforced by Rubocop_). +- Prefer `not_to` to `to_not` (_this is enforced by RuboCop_). - Try to match the ordering of tests to the ordering within the class. - Try to follow the [Four-Phase Test][four-phase-test] pattern, using newlines to separate phases. diff --git a/doc/install/installation.md b/doc/install/installation.md index b6bbc2a0af6..dc807d93bbb 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -423,6 +423,11 @@ which is the recommended location. sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production +You can specify a different Git repository by providing it as an extra paramter: + + sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse,https://example.com/gitlab-workhorse.git]" RAILS_ENV=production + + ### Initialize Database and Activate Advanced Features sudo -u git -H bundle exec rake gitlab:setup RAILS_ENV=production @@ -466,6 +471,12 @@ with setting up Gitaly until you upgrade to GitLab 9.2 or later. # Fetch Gitaly source with Git and compile with Go sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly]" RAILS_ENV=production +You can specify a different Git repository by providing it as an extra paramter: + + sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly,https://example.com/gitaly.git]" RAILS_ENV=production + +Next, make sure gitaly configured: + # Restrict Gitaly socket access sudo chmod 0700 /home/git/gitlab/tmp/sockets/private sudo chown git /home/git/gitlab/tmp/sockets/private diff --git a/doc/topics/authentication/index.md b/doc/topics/authentication/index.md index eafd2fd9d04..3e756d96ed2 100644 --- a/doc/topics/authentication/index.md +++ b/doc/topics/authentication/index.md @@ -18,6 +18,8 @@ This page gathers all the resources for the topic **Authentication** within GitL - [LDAP (Enterprise Edition)](https://docs.gitlab.com/ee/administration/auth/ldap-ee.html) - [Enforce Two-factor Authentication (2FA)](../../security/two_factor_authentication.md#enforce-two-factor-authentication-2fa) - **Articles:** + - [How to Configure LDAP with GitLab CE](../../articles/how_to_configure_ldap_gitlab_ce/index.md) + - [How to Configure LDAP with GitLab EE](https://docs.gitlab.com/articles/how_to_configure_ldap_gitlab_ee/) - [Feature Highlight: LDAP Integration](https://about.gitlab.com/2014/07/10/feature-highlight-ldap-sync/) - [Debugging LDAP](https://about.gitlab.com/handbook/support/workflows/ldap/debugging_ldap.html) - **Integrations:** diff --git a/doc/update/9.0-to-9.1.md b/doc/update/9.0-to-9.1.md index 2d597894517..2b582d4eefd 100644 --- a/doc/update/9.0-to-9.1.md +++ b/doc/update/9.0-to-9.1.md @@ -104,6 +104,7 @@ cd /home/git/gitlab-shell sudo -u git -H git fetch --all --tags sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) +sudo -u git -H bin/compile ``` ### 7. Update gitlab-workhorse diff --git a/doc/update/patch_versions.md b/doc/update/patch_versions.md index f69d567eeb7..ac1bcb8f241 100644 --- a/doc/update/patch_versions.md +++ b/doc/update/patch_versions.md @@ -75,6 +75,7 @@ cd /home/git/gitlab-shell sudo -u git -H git fetch --all --tags sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` -b v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` +sudo -u git -H sh -c 'if [ -x bin/compile ]; then bin/compile; fi' ``` ### 6. Start application diff --git a/doc/update/upgrader.md b/doc/update/upgrader.md index 5fa39ef1b0a..eb7f14a96d5 100644 --- a/doc/update/upgrader.md +++ b/doc/update/upgrader.md @@ -60,6 +60,7 @@ GitLab Shell might be outdated, running the commands below ensures you're using cd /home/git/gitlab-shell sudo -u git -H git fetch sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` +sudo -u git -H sh -c 'if [ -x bin/compile ] ; then bin/compile ; fi' ``` ## One line upgrade command @@ -78,6 +79,7 @@ cd /home/git/gitlab; \ cd /home/git/gitlab-shell; \ sudo -u git -H git fetch; \ sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION`; \ + sudo -u git -H sh -c 'if [ -x bin/compile ] ; then bin/compile ; fi'; \ cd /home/git/gitlab; \ sudo service gitlab start; \ sudo service nginx restart; \ diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index dbdc93a77a8..e15daa2feae 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -232,6 +232,7 @@ X-Gitlab-Event: Issue Hook "object_attributes": { "id": 301, "title": "New API: create/update/delete file", + "assignee_ids": [51], "assignee_id": 51, "author_id": 51, "project_id": 14, @@ -246,6 +247,11 @@ X-Gitlab-Event: Issue Hook "url": "http://example.com/diaspora/issues/23", "action": "open" }, + "assignees": [{ + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }], "assignee": { "name": "User1", "username": "user1", @@ -265,6 +271,9 @@ X-Gitlab-Event: Issue Hook }] } ``` + +**Note**: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only. + ### Comment events Triggered when a new comment is made on commits, merge requests, issues, and code snippets. @@ -544,6 +553,7 @@ X-Gitlab-Event: Note Hook "issue": { "id": 92, "title": "test", + "assignee_ids": [], "assignee_id": null, "author_id": 1, "project_id": 5, @@ -559,6 +569,8 @@ X-Gitlab-Event: Note Hook } ``` +**Note**: `assignee_id` field is deprecated and now shows the first assignee only. + #### Comment on code snippet **Request header**: diff --git a/doc/user/project/wiki/img/wiki_create_home_page.png b/doc/user/project/wiki/img/wiki_create_home_page.png Binary files differnew file mode 100644 index 00000000000..f50f564034c --- /dev/null +++ b/doc/user/project/wiki/img/wiki_create_home_page.png diff --git a/doc/user/project/wiki/img/wiki_create_new_page.png b/doc/user/project/wiki/img/wiki_create_new_page.png Binary files differnew file mode 100644 index 00000000000..c19124a8923 --- /dev/null +++ b/doc/user/project/wiki/img/wiki_create_new_page.png diff --git a/doc/user/project/wiki/img/wiki_create_new_page_modal.png b/doc/user/project/wiki/img/wiki_create_new_page_modal.png Binary files differnew file mode 100644 index 00000000000..ece437967dc --- /dev/null +++ b/doc/user/project/wiki/img/wiki_create_new_page_modal.png diff --git a/doc/user/project/wiki/img/wiki_page_history.png b/doc/user/project/wiki/img/wiki_page_history.png Binary files differnew file mode 100644 index 00000000000..0e6af1b468d --- /dev/null +++ b/doc/user/project/wiki/img/wiki_page_history.png diff --git a/doc/user/project/wiki/img/wiki_sidebar.png b/doc/user/project/wiki/img/wiki_sidebar.png Binary files differnew file mode 100644 index 00000000000..59814e2a06e --- /dev/null +++ b/doc/user/project/wiki/img/wiki_sidebar.png diff --git a/doc/user/project/wiki/index.md b/doc/user/project/wiki/index.md new file mode 100644 index 00000000000..e9ee1abc6c1 --- /dev/null +++ b/doc/user/project/wiki/index.md @@ -0,0 +1,97 @@ +# Wiki + +A separate system for documentation called Wiki, is built right into each +GitLab project. It is enabled by default on all new projects and you can find +it under **Wiki** in your project. + +Wikis are very convenient if you don't want to keep you documentation in your +repository, but you do want to keep it in the same project where your code +resides. + +You can create Wiki pages in the web interface or +[locally using Git](#adding-and-editing-wiki-pages-locally) since every Wiki is +a separate Git repository. + +>**Note:** +A [permission level][permissions] of **Guest** is needed to view a Wiki and +**Developer** is needed to create and edit Wiki pages. + +## First time creating the Home page + +The first time you visit a Wiki, you will be directed to create the Home page. +The Home page is necessary to be created since it serves as the landing page +when viewing a Wiki. You only have to fill in the **Content** section and click +**Create page**. You can always edit it later, so go ahead and write a welcome +message. + +![New home page](img/wiki_create_home_page.png) + +## Creating a new wiki page + +Create a new page by clicking the **New page** button that can be found +in all wiki pages. You will be asked to fill in the page name from which GitLab +will create the path to the page. You can specify a full path for the new file +and any missing directories will be created automatically. + +![New page modal](img/wiki_create_new_page_modal.png) + +Once you enter the page name, it's time to fill in its content. GitLab wikis +support Markdown, RDoc and AsciiDoc. For Markdown based pages, all the +[Markdown features](../../markdown.md) are supported and for links there is +some [wiki specific](../../markdown.md#wiki-specific-markdown) behavior. + +>**Note:** +The wiki is based on a Git repository and contains only text files. Uploading +files via the web interface will upload them in GitLab itself, and they will +not be available if you clone the wiki repo locally. + +In the web interface the commit message is optional, but the GitLab Wiki is +based on Git and needs a commit message, so one will be created for you if you +do not enter one. + +When you're ready, click the **Create page** and the new page will be created. + +![New page](img/wiki_create_new_page.png) + +## Editing a wiki page + +To edit a page, simply click on the **Edit** button. From there on, you can +change its content. When done, click **Save changes** for the changes to take +effect. + +## Deleting a wiki page + +You can find the **Delete** button only when editing a page. Click on it and +confirm you want the page to be deleted. + +## Viewing a list of all created wiki pages + +Every wiki has a sidebar from which a short list of the created pages can be +found. The list is ordered alphabetically. + +![Wiki sidebar](img/wiki_sidebar.png) + +If you have many pages, not all will be listed in the sidebar. Click on +**More pages** to see all of them. + +## Viewing the history of a wiki page + +The changes of a wiki page over time are recorded in the wiki's Git repository, +and you can view them by clicking the **Page history** button. + +From the history page you can see the revision of the page (Git commit SHA), its +author, the commit message, when it was last updated and the page markup format. +To see how a previous version of the page looked like, click on a revision +number. + +![Wiki page history](img/wiki_page_history.png) + +## Adding and editing wiki pages locally + +Since wikis are based on Git repositories, you can clone them locally and edit +them like you would do with every other Git repository. + +On the right sidebar, click on **Clone repository** and follow the on-screen +instructions. + +[permissions]: ../../permissions.md diff --git a/doc/workflow/img/todos_icon.png b/doc/workflow/img/todos_icon.png Binary files differindex 1ed16b09669..9fee4337a75 100644 --- a/doc/workflow/img/todos_icon.png +++ b/doc/workflow/img/todos_icon.png diff --git a/doc/workflow/img/todos_index.png b/doc/workflow/img/todos_index.png Binary files differindex 902a5aa6bd3..99c1575d157 100644 --- a/doc/workflow/img/todos_index.png +++ b/doc/workflow/img/todos_index.png diff --git a/doc/workflow/project_features.md b/doc/workflow/project_features.md index f19e7df8c9a..3f5de2bd4b1 100644 --- a/doc/workflow/project_features.md +++ b/doc/workflow/project_features.md @@ -26,6 +26,8 @@ This is a separate system for documentation, built right into GitLab. It is source controlled and is very convenient if you don't want to keep you documentation in your source code, but you do want to keep it in your GitLab project. +[Read more about Wikis.](../user/project/wiki/index.md) + ## Snippets Snippets are little bits of code or text. diff --git a/features/group/milestones.feature b/features/group/milestones.feature index d6c05df9840..1c1539b3e12 100644 --- a/features/group/milestones.feature +++ b/features/group/milestones.feature @@ -38,6 +38,7 @@ Feature: Group Milestones And I should see the "feature" label And I should see the project name in the Issue row + @javascript Scenario: I should see the Labels tab Given Group has projects with milestones When I visit group "Owned" page diff --git a/features/project/active_tab.feature b/features/project/active_tab.feature index 0d6f7350181..34201cd8486 100644 --- a/features/project/active_tab.feature +++ b/features/project/active_tab.feature @@ -63,13 +63,6 @@ Feature: Project Active Tab And no other sub tabs should be active And the active main tab should be Settings - Scenario: On Project Settings/Pages - Given I visit my project's settings page - And I click the "Pages" tab - Then the active sub tab should be Pages - And no other sub tabs should be active - And the active main tab should be Settings - Scenario: On Project Members Given I visit my project's members page Then the active sub tab should be Members diff --git a/features/project/builds/artifacts.feature b/features/project/builds/artifacts.feature index 52dc15f2eb6..5abc24949cf 100644 --- a/features/project/builds/artifacts.feature +++ b/features/project/builds/artifacts.feature @@ -17,6 +17,7 @@ Feature: Project Builds Artifacts When I visit recent build details page And I click artifacts browse button Then I should see content of artifacts archive + And I should see the build header Scenario: I browse subdirectory of build artifacts Given recent build has artifacts available @@ -25,6 +26,7 @@ Feature: Project Builds Artifacts And I click artifacts browse button And I click link to subdirectory within build artifacts Then I should see content of subdirectory within artifacts archive + And I should see the directory name in the breadcrumb Scenario: I browse directory with UTF-8 characters in name Given recent build has artifacts available @@ -44,13 +46,14 @@ Feature: Project Builds Artifacts And I navigate to parent directory of directory with invalid name Then I should not see directory with invalid name on the list + @javascript Scenario: I download a single file from build artifacts Given recent build has artifacts available And recent build has artifacts metadata available When I visit recent build details page And I click artifacts browse button And I click a link to file within build artifacts - Then download of a file extracted from build artifacts should start + Then I see a download link @javascript Scenario: I click on a row in an artifacts table diff --git a/features/project/deploy_keys.feature b/features/project/deploy_keys.feature index 960b4100ee5..6f1ed9ff5b6 100644 --- a/features/project/deploy_keys.feature +++ b/features/project/deploy_keys.feature @@ -3,28 +3,33 @@ Feature: Project Deploy Keys Given I sign in as a user And I own project "Shop" + @javascript Scenario: I should see deploy keys list Given project has deploy key When I visit project deploy keys page Then I should see project deploy key + @javascript Scenario: I should see project deploy keys Given other projects have deploy keys When I visit project deploy keys page Then I should see other project deploy key And I should only see the same deploy key once + @javascript Scenario: I should see public deploy keys Given public deploy key exists When I visit project deploy keys page Then I should see public deploy key + @javascript Scenario: I add new deploy key Given I visit project deploy keys page And I submit new deploy key Then I should be on deploy keys page And I should see newly created deploy key + @javascript Scenario: I attach other project deploy key to project Given other projects have deploy keys And I visit project deploy keys page @@ -32,6 +37,7 @@ Feature: Project Deploy Keys Then I should be on deploy keys page And I should see newly created deploy key + @javascript Scenario: I attach public deploy key to project Given public deploy key exists And I visit project deploy keys page diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index 4dee0cd23dc..1b00d8a32a0 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -82,6 +82,7 @@ Feature: Project Issues # Markdown + @javascript Scenario: Headers inside the description should have ids generated for them. Given I visit issue page "Release 0.4" Then Header "Description header" should have correct id and link diff --git a/features/project/milestone.feature b/features/project/milestone.feature index 713f0f3b979..5e7b211fa27 100644 --- a/features/project/milestone.feature +++ b/features/project/milestone.feature @@ -7,14 +7,6 @@ Feature: Project Milestone And milestone has issue "Bugfix1" with labels: "bug", "feature" And milestone has issue "Bugfix2" with labels: "bug", "enhancement" - - @javascript - Scenario: Listing issues from issues tab - Given I visit project "Shop" milestones page - And I click link "v2.2" - Then I should see the labels "bug", "enhancement" and "feature" - And I should see the "bug" label listed only once - @javascript Scenario: Listing labels from labels tab Given I visit project "Shop" milestones page diff --git a/features/project/pages.feature b/features/project/pages.feature index 87d88348d09..56e47287b5c 100644 --- a/features/project/pages.feature +++ b/features/project/pages.feature @@ -3,10 +3,15 @@ Feature: Project Pages Given I sign in as a user And I own a project - Scenario: Pages are disabled + Scenario: I cannot navigate to Pages settings if pages enabled Given pages are disabled - When I visit the Project Pages - Then I should see that GitLab Pages are disabled + And I visit my project's settings page + Then I should not see the "Pages" tab + + Scenario: I can navigate to Pages settings if pages enabled + Given pages are enabled + And I visit my project's settings page + Then I should see the "Pages" tab Scenario: I can see the pages usage if not deployed Given pages are enabled diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index c715c85c43c..bf09d7b7114 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -77,7 +77,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps step 'project "Shop" has issue "Bugfix1" with label "feature"' do project = Project.find_by(name: "Shop") - issue = create(:issue, title: "Bugfix1", project: project, assignee: current_user) + issue = create(:issue, title: "Bugfix1", project: project, assignees: [current_user]) issue.labels << project.labels.find_by(title: 'feature') end end diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb index 3225e19995b..b56558ba0d2 100644 --- a/features/steps/dashboard/todos.rb +++ b/features/steps/dashboard/todos.rb @@ -182,7 +182,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps end def issue - @issue ||= create(:issue, assignee: current_user, project: project) + @issue ||= create(:issue, assignees: [current_user], project: project) end def merge_request diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb index f8f5e3f2382..0b0983f0d06 100644 --- a/features/steps/group/milestones.rb +++ b/features/steps/group/milestones.rb @@ -1,4 +1,5 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps + include WaitForAjax include SharedAuthentication include SharedPaths include SharedGroup @@ -90,6 +91,8 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps end step 'I should see the list of labels' do + wait_for_ajax + page.within('#tab-labels') do expect(page).to have_content 'bug' expect(page).to have_content 'feature' @@ -110,7 +113,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user, milestone: milestone @@ -122,7 +125,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps issue = create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user, milestone: milestone diff --git a/features/steps/groups.rb b/features/steps/groups.rb index 4dc87dc4d9c..83d8abbab1f 100644 --- a/features/steps/groups.rb +++ b/features/steps/groups.rb @@ -61,7 +61,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps step 'project from group "Owned" has issues assigned to me' do create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user end @@ -123,7 +123,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps step 'the archived project have some issues' do create :issue, project: @archived_project, - assignee: current_user, + assignees: [current_user], author: current_user end diff --git a/features/steps/project/active_tab.rb b/features/steps/project/active_tab.rb index 4befd49ac81..5cd9bd38c9d 100644 --- a/features/steps/project/active_tab.rb +++ b/features/steps/project/active_tab.rb @@ -39,12 +39,6 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps end end - step 'I click the "Pages" tab' do - page.within '.sub-nav' do - click_link('Pages') - end - end - step 'I click the "Activity" tab' do page.within '.sub-nav' do click_link('Activity') diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index be0f6eee55a..eec375b0532 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -3,6 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps include SharedProject include SharedBuilds include RepoHelpers + include WaitForAjax step 'I click artifacts download button' do click_link 'Download' @@ -22,6 +23,12 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps end end + step 'I should see the build header' do + page.within('.build-header') do + expect(page).to have_content "Job ##{@build.id} in pipeline ##{@pipeline.id} for commit #{@pipeline.short_sha}" + end + end + step 'I click link to subdirectory within build artifacts' do page.within('.tree-table') { click_link 'other_artifacts_0.1.2' } end @@ -34,6 +41,12 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps end end + step 'I should see the directory name in the breadcrumb' do + page.within('.repo-breadcrumb') do + expect(page).to have_content 'other_artifacts_0.1.2' + end + end + step 'recent build artifacts contain directory with UTF-8 characters' do # metadata fixture contains relevant directory end @@ -66,19 +79,11 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I click a link to file within build artifacts' do page.within('.tree-table') { find_link('ci_artifacts.txt').click } + wait_for_ajax end - step 'download of a file extracted from build artifacts should start' do - send_data = response_headers[Gitlab::Workhorse::SEND_DATA_HEADER] - - expect(send_data).to start_with('artifacts-entry:') - - base64_params = send_data.sub(/\Aartifacts\-entry:/, '') - params = JSON.parse(Base64.urlsafe_decode64(base64_params)) - - expect(params.keys).to eq(%w(Archive Entry)) - expect(params['Archive']).to end_with('build_artifacts.zip') - expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + step 'I see a download link' do + expect(page).to have_link 'download it' end step 'I click a first row within build artifacts table' do diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb index ec59a2c094e..8ad9d4a4741 100644 --- a/features/steps/project/deploy_keys.rb +++ b/features/steps/project/deploy_keys.rb @@ -8,19 +8,19 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should see project deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content deploy_key.title end end step 'I should see other project deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content other_deploy_key.title end end step 'I should see public deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content public_deploy_key.title end end @@ -40,7 +40,8 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should see newly created deploy key' do - page.within '.deploy-keys' do + @project.reload + page.within(find('.deploy-keys')) do expect(page).to have_content(deploy_key.title) end end @@ -56,7 +57,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should only see the same deploy key once' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_selector('ul li', count: 1) end end @@ -66,8 +67,9 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I click attach deploy key' do - page.within '.deploy-keys' do - click_link 'Enable' + page.within(find('.deploy-keys')) do + click_button 'Enable' + expect(page).not_to have_selector('.fa-spinner') end end diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index a06b2f2911f..4b7d6cd840b 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -458,6 +458,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_button "Comment" end + wait_for_ajax + page.within ".files>div:nth-child(2) .note-body > .note-text" do expect(page).to have_content "Line is correct" end @@ -470,6 +472,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps fill_in "note_note", with: "Line is wrong on here" click_button "Comment" end + + wait_for_ajax end step 'I should still see a comment like "Line is correct" in the second file' do @@ -574,6 +578,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps fill_in "note_note", with: message click_button "Comment" end + + wait_for_ajax + page.within(".notes_holder", visible: true) do expect(page).to have_content message end diff --git a/features/steps/project/pages.rb b/features/steps/project/pages.rb index 4045955a8b9..fea82d9fb57 100644 --- a/features/steps/project/pages.rb +++ b/features/steps/project/pages.rb @@ -18,14 +18,22 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps visit namespace_project_pages_path(@project.namespace, @project) end - step 'I should see that GitLab Pages are disabled' do - expect(page).to have_content('GitLab Pages are disabled') - end - step 'I should see the usage of GitLab Pages' do expect(page).to have_content('Configure pages') end + step 'I should see the "Pages" tab' do + page.within '.sub-nav' do + expect(page).to have_link('Pages') + end + end + + step 'I should not see the "Pages" tab' do + page.within '.sub-nav' do + expect(page).not_to have_link('Pages') + end + end + step 'pages are deployed' do pipeline = @project.ensure_pipeline('HEAD', @project.commit('HEAD').sha) build = build(:ci_build, diff --git a/features/steps/project/project_milestone.rb b/features/steps/project/project_milestone.rb index 1864b3a2b52..dc1190b7eea 100644 --- a/features/steps/project/project_milestone.rb +++ b/features/steps/project/project_milestone.rb @@ -2,6 +2,7 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps include SharedAuthentication include SharedProject include SharedPaths + include WaitForAjax step 'milestone has issue "Bugfix1" with labels: "bug", "feature"' do project = Project.find_by(name: "Shop") @@ -34,6 +35,8 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps end step 'I should see the labels "bug", "enhancement" and "feature"' do + wait_for_ajax + page.within('#tab-issues') do expect(page).to have_content 'bug' expect(page).to have_content 'enhancement' diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb index 7885cc7ab77..7d260025052 100644 --- a/features/steps/shared/note.rb +++ b/features/steps/shared/note.rb @@ -24,6 +24,8 @@ module SharedNote fill_in "note[note]", with: "XML attached" click_button "Comment" end + + wait_for_ajax end step 'I preview a comment text like "Bug fixed :smile:"' do @@ -37,6 +39,8 @@ module SharedNote page.within(".js-main-target-form") do click_button "Comment" end + + wait_for_ajax end step 'I write a comment like ":+1: Nice"' do diff --git a/features/support/env.rb b/features/support/env.rb index 06c804b1db7..92d13bea4b6 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -10,7 +10,7 @@ if ENV['CI'] Knapsack::Adapters::SpinachAdapter.bind end -%w(select2_helper test_env repo_helpers wait_for_ajax sidekiq).each do |f| +%w(select2_helper test_env repo_helpers wait_for_ajax wait_for_requests sidekiq).each do |f| require Rails.root.join('spec', 'support', f) end @@ -30,6 +30,13 @@ Spinach.hooks.before_run do include FactoryGirl::Syntax::Methods end +Spinach.hooks.after_feature do |feature_data| + if feature_data.scenarios.flat_map(&:tags).include?('javascript') + include WaitForRequests + wait_for_requests_complete + end +end + module StdoutReporterWithScenarioLocation # Override the standard reporter to show filename and line number next to each # scenario for easy, focused re-runs diff --git a/fixtures/emojis/digests.json b/fixtures/emojis/digests.json index 3cbc4702dac..589cff165f3 100644 --- a/fixtures/emojis/digests.json +++ b/fixtures/emojis/digests.json @@ -2,10746 +2,12537 @@ "100": { "category": "symbols", "moji": "💯", + "description": "hundred points symbol", "unicodeVersion": "6.0", "digest": "add3bd7d06b6dd445788b277f8c9e5dcf42a54d3ec8b7fb9e7a39695dd95d094" }, "1234": { "category": "symbols", "moji": "🔢", + "description": "input symbol for numbers", "unicodeVersion": "6.0", "digest": "c5ac5c8147f5bfd644fad6b470432bba86ffc7bcee04a0e0d277cd1ca485207f" }, "8ball": { "category": "activity", "moji": "🎱", + "description": "billiards", "unicodeVersion": "6.0", "digest": "a6e6855775b66c505adee65926a264103ebddf2e2d963db7c009b4fec3a24178" }, "a": { "category": "symbols", "moji": "🅰", + "description": "negative squared latin capital letter a", "unicodeVersion": "6.0", "digest": "bddbb39e8a1d35d42b7c08e7d47f63988cb4d8614b79f74e70b9c67c221896cc" }, "ab": { "category": "symbols", "moji": "🆎", + "description": "negative squared ab", "unicodeVersion": "6.0", "digest": "67430fe5fce981160e2ea9052962e49f264322d3abfc2828fbc311b6cdf67ae8" }, "abc": { "category": "symbols", "moji": "🔤", + "description": "input symbol for latin letters", "unicodeVersion": "6.0", "digest": "282c817ee3414d77a74b815962c33dd9fe71fabaea8c7a9cec466100fbe32187" }, "abcd": { "category": "symbols", "moji": "🔡", + "description": "input symbol for latin small letters", "unicodeVersion": "6.0", "digest": "686728c759f4683c64762ee4eda0a91bf2041f0ae4f358aacf6c09bf51892eff" }, "accept": { "category": "symbols", "moji": "🉑", + "description": "circled ideograph accept", "unicodeVersion": "6.0", "digest": "7208d34c761f10a7fd28f98e25535eba13ff91a64442fc282a98bb77722614f1" }, "aerial_tramway": { "category": "travel", "moji": "🚡", + "description": "aerial tramway", "unicodeVersion": "6.0", "digest": "98df666f34370fc34ce280d84bba5a7e617f733fbbfe66caa424b2afa6ab6777" }, "airplane": { "category": "travel", "moji": "✈", + "description": "airplane", "unicodeVersion": "1.1", "digest": "cc12cf259ef88e57717620cd2bd5aa6a02a8631ee532a3bde24bee78edc5de33" }, "airplane_arriving": { "category": "travel", "moji": "🛬", + "description": "airplane arriving", "unicodeVersion": "7.0", "digest": "80d5b4675f91c4cff06d146d795a065b0ce2a74557df4d9e3314e3d3b5c4ae82" }, "airplane_departure": { "category": "travel", "moji": "🛫", + "description": "airplane departure", "unicodeVersion": "7.0", "digest": "5544eace06b8e1b6ea91940e893e013d33d6b166e14e6d128a87f2cd2de88332" }, "airplane_small": { "category": "travel", "moji": "🛩", + "description": "small airplane", "unicodeVersion": "7.0", "digest": "1a2e07abbbe90d05cee7ff8dd52f443d595ccb38959f3089fe016b77e5d6de7d" }, "alarm_clock": { "category": "objects", "moji": "⏰", + "description": "alarm clock", "unicodeVersion": "6.0", "digest": "fef05a3cd1cddbeca4de8091b94bddb93790b03fa213da86c0eec420f8c49599" }, "alembic": { "category": "objects", "moji": "⚗", + "description": "alembic", "unicodeVersion": "4.1", "digest": "c94b2a4bf24ccf4db27a22c9725cfe900f4a99ec49ef2411d67952bcb2ca1bfb" }, "alien": { "category": "people", "moji": "👽", + "description": "extraterrestrial alien", "unicodeVersion": "6.0", "digest": "856ba98202b244c13a5ee3014a6f7ad592d8c119a30d79e4fc790b74b0e321f7" }, "ambulance": { "category": "travel", "moji": "🚑", + "description": "ambulance", "unicodeVersion": "6.0", "digest": "d9b3c1873de496a4554e715342c72290fb69a9c6766d7885f38bfe9491d052da" }, "amphora": { "category": "objects", "moji": "🏺", + "description": "amphora", "unicodeVersion": "8.0", "digest": "4015f907b649b5e348502cc0e3685ed184e180dca5cc81c43ec516e14df127bf" }, "anchor": { "category": "travel", "moji": "⚓", + "description": "anchor", "unicodeVersion": "4.1", "digest": "2b29b34ef896ebab70016301e3d1880209bbc3c5a5b8d832e43afff9b17ad792" }, "angel": { "category": "people", "moji": "👼", + "description": "baby angel", "unicodeVersion": "6.0", "digest": "db75c2460aaf9cd07cb41fe22c8a6079f3667ffe612a71611358720e2b5512a4" }, "angel_tone1": { "category": "people", "moji": "👼🏻", + "description": "baby angel tone 1", "unicodeVersion": "8.0", "digest": "5871a622469b96296365adaf77d83167759692124c20e5a6e062a525af33472a" }, "angel_tone2": { "category": "people", "moji": "👼🏼", + "description": "baby angel tone 2", "unicodeVersion": "8.0", "digest": "f5993198a5d9daf39e761c783461f07bca237f4e9b739ac300bb8ca001a69a1a" }, "angel_tone3": { "category": "people", "moji": "👼🏽", + "description": "baby angel tone 3", "unicodeVersion": "8.0", "digest": "f0c97a7c4354626267d6ab0f388e4297ad255ab9b061f9c68fbcaa0abfc52783" }, "angel_tone4": { "category": "people", "moji": "👼🏾", + "description": "baby angel tone 4", "unicodeVersion": "8.0", "digest": "6e5dc724c1939d1b0d1a91343662b5bd61ced7709c97802977145ffab6a1f7ac" }, "angel_tone5": { "category": "people", "moji": "👼🏿", + "description": "baby angel tone 5", "unicodeVersion": "8.0", "digest": "52186e1de350c27d25d6010edf44f64a30338b65912ca178429fbcfbd88113c2" }, "anger": { "category": "symbols", "moji": "💢", + "description": "anger symbol", "unicodeVersion": "6.0", "digest": "332493913891aa0eda2743b4bb16c4682400f249998bf34eb292246c9009e17f" }, "anger_right": { "category": "symbols", "moji": "🗯", + "description": "right anger bubble", "unicodeVersion": "7.0", "digest": "8b049511ef3b1b28325841e2f87c60773eaf2f65cabba58d8b0ec3de9b10c0ae" }, "angry": { "category": "people", "moji": "😠", + "description": "angry face", "unicodeVersion": "6.0", "digest": "7e09e7e821f511606341fb5ce4011a8ed9809766ab86b7983ffa6ea352b39ec1" }, "ant": { "category": "nature", "moji": "🐜", + "description": "ant", "unicodeVersion": "6.0", "digest": "929abeaff7ba21ab71cd1ab798af7a6b611e3b3ce1af80cede09a116b223e442" }, "apple": { "category": "food", "moji": "🍎", + "description": "red apple", "unicodeVersion": "6.0", "digest": "2a1b85ce57e3d236ae7777dcf332ec37d03bfd7b19806521a353bc532083224d" }, "aquarius": { "category": "symbols", "moji": "♒", + "description": "aquarius", "unicodeVersion": "1.1", "digest": "fdc42cd41b0dace5eae6baba3143f1e40295d48a29e7103a5bba1d84a056c39d" }, "aries": { "category": "symbols", "moji": "♈", + "description": "aries", "unicodeVersion": "1.1", "digest": "deb135debcde0a98f40361a84ab64d57c18b5b445cd2f4199e8936f052899737" }, "arrow_backward": { "category": "symbols", "moji": "◀", + "description": "black left-pointing triangle", "unicodeVersion": "1.1", "digest": "e162ac82e90d1e925d479fa5c45b9340e0a53287be04e43cbbb2a89c7e7e45e4" }, "arrow_double_down": { "category": "symbols", "moji": "⏬", + "description": "black down-pointing double triangle", "unicodeVersion": "6.0", "digest": "03ca890b05338d40972c7a056d672df620a203c6ca52ff3ff530f1a710905507" }, "arrow_double_up": { "category": "symbols", "moji": "⏫", + "description": "black up-pointing double triangle", "unicodeVersion": "6.0", "digest": "e753f05bce993d62d5dc79e33c441ced059381b6ce21fa3ea4200f1b3236e59d" }, "arrow_down": { "category": "symbols", "moji": "⬇", + "description": "downwards black arrow", "unicodeVersion": "4.0", "digest": "9bf1bd2ea652ca9321087de58c7a112ea04c35676a6ee0766154183f8b95af6c" }, "arrow_down_small": { "category": "symbols", "moji": "🔽", + "description": "down-pointing small red triangle", "unicodeVersion": "6.0", "digest": "7766198bc60cf59d6cdaeeaa700c2282bfff2f0fdeb22cf4581ca284b87a3bb7" }, "arrow_forward": { "category": "symbols", "moji": "▶", + "description": "black right-pointing triangle", "unicodeVersion": "1.1", "digest": "db77d9accd1e02224f5d612f79cd691e6befdf22063475204836be6572510fb7" }, "arrow_heading_down": { "category": "symbols", "moji": "⤵", + "description": "arrow pointing rightwards then curving downwards", "unicodeVersion": "3.2", "digest": "f5396069c8f63c13e6c3e0ecd34267c932451309ade9c1171d410563153bf909" }, "arrow_heading_up": { "category": "symbols", "moji": "⤴", + "description": "arrow pointing rightwards then curving upwards", "unicodeVersion": "3.2", "digest": "1cad71923fa3df24cf543cae4ce775b0f74936f2edd685fd86a7525c41a14568" }, "arrow_left": { "category": "symbols", "moji": "⬅", + "description": "leftwards black arrow", "unicodeVersion": "4.0", "digest": "b629bb3dbe161ef89cfcfced0c7968a68e44a019ad509132987e4973bdc874e7" }, "arrow_lower_left": { "category": "symbols", "moji": "↙", + "description": "south west arrow", "unicodeVersion": "1.1", "digest": "879136ba0e24e6bf3be70118abcb716d71bd74f7b62347bc052b6533c0ea534d" }, "arrow_lower_right": { "category": "symbols", "moji": "↘", + "description": "south east arrow", "unicodeVersion": "1.1", "digest": "86d52ac9b961991e3aaa6a9f9b5ace4db6ffd1b5c171c09c23b516473b55066d" }, "arrow_right": { "category": "symbols", "moji": "➡", + "description": "black rightwards arrow", "unicodeVersion": "1.1", "digest": "45f26a1cbb0f00ed3609b39da52e9d9e896a77e361c4c8036b1bf8038171bd49" }, "arrow_right_hook": { "category": "symbols", "moji": "↪", + "description": "rightwards arrow with hook", "unicodeVersion": "1.1", "digest": "4f452679c71bcea4fc4a701c55156fef3ddc1ebbc70570bedfc9d3a029637ab1" }, "arrow_up": { "category": "symbols", "moji": "⬆", + "description": "upwards black arrow", "unicodeVersion": "4.0", "digest": "982b988ef6651d8a71867ba7c87f640f62dd0eeb0b7c358f5a5c37e8fe507b8b" }, "arrow_up_down": { "category": "symbols", "moji": "↕", + "description": "up down arrow", "unicodeVersion": "1.1", "digest": "645ed8fb6646f49bfd95af1752336deacdadbe5cba13904023a704288f3b0e2c" }, "arrow_up_small": { "category": "symbols", "moji": "🔼", + "description": "up-pointing small red triangle", "unicodeVersion": "6.0", "digest": "4a8c5789c13a852517e639e7a62c2d331464e6fb0358985aa97c1515e97b5e8b" }, "arrow_upper_left": { "category": "symbols", "moji": "↖", + "description": "north west arrow", "unicodeVersion": "1.1", "digest": "79026f828d6ceb7c55a9542770962ba6dcd08203995f6ceeb70333a12307d376" }, "arrow_upper_right": { "category": "symbols", "moji": "↗", + "description": "north east arrow", "unicodeVersion": "1.1", "digest": "7e0f33dfbe65628991c170130d366a3e2cedaf8862ddfcaf3960f395d3da1926" }, "arrows_clockwise": { "category": "symbols", "moji": "🔃", + "description": "clockwise downwards and upwards open circle arrows", "unicodeVersion": "6.0", "digest": "88669679977f7157f0acaa9d6a1b77ccf84d25eb78c5bc8afcde38d3635e7144" }, "arrows_counterclockwise": { "category": "symbols", "moji": "🔄", + "description": "anticlockwise downwards and upwards open circle ar", "unicodeVersion": "6.0", "digest": "a2c6a6d3643c128aee3304cd03bb3d7cfe4d35d3ba825bc9c1142d7832b4426e" }, "art": { "category": "activity", "moji": "🎨", + "description": "artist palette", "unicodeVersion": "6.0", "digest": "b6bc6c4bfb594aadcbb641d006031867678504764bbe0ab84e7b08567a9498da" }, "articulated_lorry": { "category": "travel", "moji": "🚛", + "description": "articulated lorry", "unicodeVersion": "6.0", "digest": "c115e6613ebd718268aa31d265e017138b9fb58bbb8201eb3f40de2380e460aa" }, "asterisk": { "category": "symbols", "moji": "*⃣", + "description": "keycap asterisk", "unicodeVersion": "3.0", "digest": "33d92093f2914448d5a939cf62e8ee3e32931923abdef5f0210e8a8150fa312d" }, "astonished": { "category": "people", "moji": "😲", + "description": "astonished face", "unicodeVersion": "6.0", "digest": "f8531bdda5070d10492709085f4ff652b8be9be6458758940358b9fc594a1f14" }, "athletic_shoe": { "category": "people", "moji": "👟", + "description": "athletic shoe", "unicodeVersion": "6.0", "digest": "1f90dc390e0dea679085465b7f9e786dfd7dd56a3b219987144ed37ab1e9bf95" }, "atm": { "category": "symbols", "moji": "🏧", + "description": "automated teller machine", "unicodeVersion": "6.0", "digest": "7d3ce6a6afb4951546883404b8e36904179f88f1aa533706cf7bf0bbe0d6fd3c" }, "atom": { "category": "symbols", "moji": "⚛", + "description": "atom symbol", "unicodeVersion": "4.1", "digest": "6b6bb83b00707a314e46ff8eefbda40978a291ec7881caba1b1ee273f49c1368" }, "avocado": { "category": "food", "moji": "🥑", + "description": "avocado", "unicodeVersion": "9.0", "digest": "bc1fb203d63b18985598400925de24050bb192afda1cbf0813f85cb139869eff" }, "b": { "category": "symbols", "moji": "🅱", + "description": "negative squared latin capital letter b", "unicodeVersion": "6.0", "digest": "722f9db9442e7c0fc0d0ac0f5291fbf47c6a0ac4d8abd42e97957da705fb82bf" }, "baby": { "category": "people", "moji": "👶", + "description": "baby", "unicodeVersion": "6.0", "digest": "219ae5a571aaf90c060956cd1c56dcc27708c827cecdca3ba1122058a3c4847b" }, "baby_bottle": { "category": "food", "moji": "🍼", + "description": "baby bottle", "unicodeVersion": "6.0", "digest": "4fb71689e9d634e8d1699cf454a71e43f2b5b1a5dbab0bf186626934fdf5b782" }, "baby_chick": { "category": "nature", "moji": "🐤", + "description": "baby chick", "unicodeVersion": "6.0", "digest": "14119874e9b5548028dfb9cc593a541efc1d075ac839a565b92e0c3253cffe7e" }, "baby_symbol": { "category": "symbols", "moji": "🚼", + "description": "baby symbol", "unicodeVersion": "6.0", "digest": "fb4db66868cda45ea3879ffc2ff4f763c56d2d889ae0ab17fe171129ede02f98" }, "baby_tone1": { "category": "people", "moji": "👶🏻", + "description": "baby tone 1", "unicodeVersion": "8.0", "digest": "cd3faf223a298c34e05d469d9d0db08438d97df7fd82c0973f8a9e07d553f5b1" }, "baby_tone2": { "category": "people", "moji": "👶🏼", + "description": "baby tone 2", "unicodeVersion": "8.0", "digest": "5b4539e22e0dd726c27eb8af2357f9240a52aed3f710f3234571cff029cc6198" }, "baby_tone3": { "category": "people", "moji": "👶🏽", + "description": "baby tone 3", "unicodeVersion": "8.0", "digest": "720e740e1ac63c6372269132b1fb6e07a6b91f5c808cc3adef59f0b4500e5e72" }, "baby_tone4": { "category": "people", "moji": "👶🏾", + "description": "baby tone 4", "unicodeVersion": "8.0", "digest": "5e43b69c509bd526ad6f081764578c30b6f3285fb7442222e05ccf62e53bfb64" }, "baby_tone5": { "category": "people", "moji": "👶🏿", + "description": "baby tone 5", "unicodeVersion": "8.0", "digest": "85bba6e0940ccfb99999fe124e815f9dd340d00a5568e13967b02245a62dbf54" }, "back": { "category": "symbols", "moji": "🔙", + "description": "back with leftwards arrow above", "unicodeVersion": "6.0", "digest": "083e4e48b51092c28efb4532e840e1091b5d4b685c6e0f221aa0228f061cd91e" }, "bacon": { "category": "food", "moji": "🥓", + "description": "bacon", "unicodeVersion": "9.0", "digest": "18ad3817f1f88a69706db5727a58e763dde6c21a2d4f184c3d728c32dc5fa05a" }, "badminton": { "category": "activity", "moji": "🏸", + "description": "badminton racquet", "unicodeVersion": "8.0", "digest": "353eb7ee93decd9fe0072e4d78a5618d5e2d9e77a6e4de9fe171870d75e02a66" }, "baggage_claim": { "category": "symbols", "moji": "🛄", + "description": "baggage claim", "unicodeVersion": "6.0", "digest": "7d6bceca92c266da6d2b91dfcf244546fc11022e039e7da8e6888c1696bb2186" }, "balloon": { "category": "objects", "moji": "🎈", + "description": "balloon", "unicodeVersion": "6.0", "digest": "65760aedc1503b426927cff78c24449d563843a274961d962718fa9638375d54" }, "ballot_box": { "category": "objects", "moji": "🗳", + "description": "ballot box with ballot", "unicodeVersion": "7.0", "digest": "4175a56eca5c6458574a681e109b1403fbb143cf27f69ae6c1917650f3e08892" }, "ballot_box_with_check": { "category": "symbols", "moji": "☑", + "description": "ballot box with check", "unicodeVersion": "1.1", "digest": "c98d6f3588dd87e2f318bbfe6c646399a905450edfd814edae4e5b1bddef2134" }, "bamboo": { "category": "nature", "moji": "🎍", + "description": "pine decoration", "unicodeVersion": "6.0", "digest": "e4ee65088df43d7081b1ce6fd996f66f3e0accd88840855c47a98a22997823dd" }, "banana": { "category": "food", "moji": "🍌", + "description": "banana", "unicodeVersion": "6.0", "digest": "f9e8ff910c282c20a8907ff64926b5de4ee250529a1ed718fb33302e6fff8dd9" }, "bangbang": { "category": "symbols", "moji": "‼", + "description": "double exclamation mark", "unicodeVersion": "1.1", "digest": "76536fee63fe964a3f3839d309b1f45028fb0c43f4d1eeee495f17e1532b4def" }, "bank": { "category": "travel", "moji": "🏦", + "description": "bank", "unicodeVersion": "6.0", "digest": "f5d2976bf6d521638ccacc74be06bd4abfeab06c5d898a9d245edad45a5b6306" }, "bar_chart": { "category": "objects", "moji": "📊", + "description": "bar chart", "unicodeVersion": "6.0", "digest": "65a328a1b2d7a5332dd4d93f4dbca13d976f0a505b00835c3fc458e394804240" }, "barber": { "category": "objects", "moji": "💈", + "description": "barber pole", "unicodeVersion": "6.0", "digest": "5e8053d3bb3765a8632fd1cbfe21163f74ed79f6be377eb9603eaaf883d8dc46" }, "baseball": { "category": "activity", "moji": "⚾", + "description": "baseball", "unicodeVersion": "5.2", "digest": "46ac16f8b5455b942f6dbff9483a6fd277721e6719d2731573baabd21c44b34f" }, "basketball": { "category": "activity", "moji": "🏀", + "description": "basketball and hoop", "unicodeVersion": "6.0", "digest": "cc83e2aea8fcd2e9a5789e1932ee3766c40843c142fd3565c4e77dafb21ec7d7" }, "basketball_player": { "category": "activity", "moji": "⛹", + "description": "person with ball", "unicodeVersion": "5.2", "digest": "793ba53c95e8def769383b612037bc9b9bceecaf1e0430c50a4cc128ad18d9b9" }, "basketball_player_tone1": { "category": "activity", "moji": "⛹🏻", + "description": "person with ball tone 1", "unicodeVersion": "8.0", "digest": "2a06522b971e68ee5b8777a58253009b548f4da2fb723c638acb3d7b04edba8f" }, "basketball_player_tone2": { "category": "activity", "moji": "⛹🏼", + "description": "person with ball tone 2", "unicodeVersion": "8.0", "digest": "ecc0e44ab9bc478ba45a055fd69a3a38377b917aac5047963fe80ff8ae5fd8e3" }, "basketball_player_tone3": { "category": "activity", "moji": "⛹🏽", + "description": "person with ball tone 3", "unicodeVersion": "8.0", "digest": "2d38f1851c685d29532c042461d7b5b996e5f04f0ed54857c66073c62a99ceac" }, "basketball_player_tone4": { "category": "activity", "moji": "⛹🏾", + "description": "person with ball tone 4", "unicodeVersion": "8.0", "digest": "09e957c6e9ffc196415f28073aa261feba8efba0bdc694dc08f8f7cd1f88f720" }, "basketball_player_tone5": { "category": "activity", "moji": "⛹🏿", + "description": "person with ball tone 5", "unicodeVersion": "8.0", "digest": "c631cefc5d2a0a31bdb9f0a0d97ea68b1c6928e565468998403034644572a0b0" }, "bat": { "category": "nature", "moji": "🦇", + "description": "bat", "unicodeVersion": "9.0", "digest": "8fc19e0d7d6f80906bdbc06d616a810de66180d96cf28070a53fa61b88904535" }, "bath": { "category": "activity", "moji": "🛀", + "description": "bath", "unicodeVersion": "6.0", "digest": "33b371832f90aad50baf5296f3ad4cc081c319b279f989c74409903d8568e917" }, "bath_tone1": { "category": "activity", "moji": "🛀🏻", + "description": "bath tone 1", "unicodeVersion": "8.0", "digest": "7ae2989e47788ba71359d52da68feec95aaff68a77d5a6556957df1617af8536" }, "bath_tone2": { "category": "activity", "moji": "🛀🏼", + "description": "bath tone 2", "unicodeVersion": "8.0", "digest": "2e86f8edad54d15a7094cd52160cbe51d10aa1750cfb0b3b58e93533f070e327" }, "bath_tone3": { "category": "activity", "moji": "🛀🏽", + "description": "bath tone 3", "unicodeVersion": "8.0", "digest": "654c0cd083a67ff330a38d07352876d265390e5399e5352598d64a6c7e5eeba7" }, "bath_tone4": { "category": "activity", "moji": "🛀🏾", + "description": "bath tone 4", "unicodeVersion": "8.0", "digest": "adad88c6830f31c4b5be194d1987d6aadf4adf45e4cb7f2e4657f0d20c0d663a" }, "bath_tone5": { "category": "activity", "moji": "🛀🏿", + "description": "bath tone 5", "unicodeVersion": "8.0", "digest": "952c4c9bf24e001e23a33ebf97bd92969cd9143e28ce93f9aafc708a8f966903" }, "bathtub": { "category": "objects", "moji": "🛁", + "description": "bathtub", "unicodeVersion": "6.0", "digest": "844dffb87ef872594195069b0d0df27c3fe51f3967ccbc8b2df811a086dd483a" }, "battery": { "category": "objects", "moji": "🔋", + "description": "battery", "unicodeVersion": "6.0", "digest": "949ae06648667fb13d9121a6dfdd03bf8692794b28c36e9a8e8ac4515664449a" }, "beach": { "category": "travel", "moji": "🏖", + "description": "beach with umbrella", "unicodeVersion": "7.0", "digest": "37fa2158977d470186caaa1aa06669b6dc5026ba49a0c44c5255541f8e974e26" }, "beach_umbrella": { "category": "objects", "moji": "⛱", + "description": "umbrella on ground", "unicodeVersion": "5.2", "digest": "d045f1de10038b9fb1eaa2529b2f80b7e3be1cff503efcc2d680663d1fbbc18f" }, "bear": { "category": "nature", "moji": "🐻", + "description": "bear face", "unicodeVersion": "6.0", "digest": "a4b9066eaa5681e6af06e596a96a5217037460ffc3b013e8db4d34d762413246" }, "bed": { "category": "objects", "moji": "🛏", + "description": "bed", "unicodeVersion": "7.0", "digest": "08f6e20db51b1fb650b390a0a3074938646772f3fcee8c295d47742e44fe1e30" }, "bee": { "category": "nature", "moji": "🐝", + "description": "honeybee", "unicodeVersion": "6.0", "digest": "5beb9a1650681b4adf69999d4808231c38f41a3ec693480b807cda86f964c570" }, "beer": { "category": "food", "moji": "🍺", + "description": "beer mug", "unicodeVersion": "6.0", "digest": "69e227104976548ee0f37375fe1526fd65ef0a328d2d92db2feb1edfd7032bd4" }, "beers": { "category": "food", "moji": "🍻", + "description": "clinking beer mugs", "unicodeVersion": "6.0", "digest": "db8b32d93bf6d161a3b027e55651d8f51231b13928b3610987ef62bb634d7501" }, "beetle": { "category": "nature", "moji": "🐞", + "description": "lady beetle", "unicodeVersion": "6.0", "digest": "5aaa428e3f63f7cd1696839ab05be03fa0cd0cbed30a05c36cb270da330c3849" }, "beginner": { "category": "symbols", "moji": "🔰", + "description": "japanese symbol for beginner", "unicodeVersion": "6.0", "digest": "2de4fdf92f182c42b12b7527034eaf767d996848b61f31ee69167728411ca0b1" }, "bell": { "category": "symbols", "moji": "🔔", + "description": "bell", "unicodeVersion": "6.0", "digest": "18d419417746ead408072b78fe2edb6314cdb49492873966fa9f9f06be09899b" }, "bellhop": { "category": "objects", "moji": "🛎", + "description": "bellhop bell", "unicodeVersion": "7.0", "digest": "b8187bc4059f6a0924a47fe3f6c07f656bed0334bbcbfa1e89f800fe6594ff08" }, "bento": { "category": "food", "moji": "🍱", + "description": "bento box", "unicodeVersion": "6.0", "digest": "d46d4f681c5da7f7678b51be3445454a8ed18d917e132ae79077f05310e485f1" }, "bicyclist": { "category": "activity", "moji": "🚴", + "description": "bicyclist", "unicodeVersion": "6.0", "digest": "3302147b6b47c16adb97d78b7b761a1ca80e6d0b41d0b60f4da338d2f55f968b" }, "bicyclist_tone1": { "category": "activity", "moji": "🚴🏻", + "description": "bicyclist tone 1", "unicodeVersion": "8.0", "digest": "27eaae0eb61f5e7b3cd9faf02c042d6643a368051a7c9d7da4e0fb9802d39242" }, "bicyclist_tone2": { "category": "activity", "moji": "🚴🏼", + "description": "bicyclist tone 2", "unicodeVersion": "8.0", "digest": "39ee9e1071700da7079ad0146bf5711c3a222991eeca8b29b72a65677604444d" }, "bicyclist_tone3": { "category": "activity", "moji": "🚴🏽", + "description": "bicyclist tone 3", "unicodeVersion": "8.0", "digest": "03e1d2c4232c896147a9d4bf43becd61edbb5c84fc7193ecea474c0f9fb36817" }, "bicyclist_tone4": { "category": "activity", "moji": "🚴🏾", + "description": "bicyclist tone 4", "unicodeVersion": "8.0", "digest": "61393d9c4805be0379d86dd5bec9a1b02314433ab36cfd85bb48dfd073746617" }, "bicyclist_tone5": { "category": "activity", "moji": "🚴🏿", + "description": "bicyclist tone 5", "unicodeVersion": "8.0", "digest": "2b46d5f8303e5710dbf5db3a4edc9d88a032fe123fe79158024c9f51df5458c6" }, "bike": { "category": "travel", "moji": "🚲", + "description": "bicycle", "unicodeVersion": "6.0", "digest": "b41daa7c549d483e2336186a28baaa8ecb11986f490c0c54c793c44900c8f652" }, "bikini": { "category": "people", "moji": "👙", + "description": "bikini", "unicodeVersion": "6.0", "digest": "07fe156f64673818d69ce3bf03950ca59e3b5d346e45ca541da4078ab791f5ae" }, "biohazard": { "category": "symbols", "moji": "☣", + "description": "biohazard sign", "unicodeVersion": "1.1", "digest": "96163e31f0b8dc5a59772133ede9cc2f40f94330d0b15e3d044b28747e2be788" }, "bird": { "category": "nature", "moji": "🐦", + "description": "bird", "unicodeVersion": "6.0", "digest": "f916eaf8f271b3767ade9eabb69594c0479f45472d471cabaf59f6e965c161e0" }, "birthday": { "category": "food", "moji": "🎂", + "description": "birthday cake", "unicodeVersion": "6.0", "digest": "89e7c4c598ebee8ec8ab11ebe4ccc6defb7c4d2987ee2379a19b3b59827dd98a" }, "black_circle": { "category": "symbols", "moji": "⚫", + "description": "medium black circle", "unicodeVersion": "4.1", "digest": "c2ba672994ad0f99d7fdc449f3fee45a2dca68a58f9fe95825b38465a30ef44e" }, "black_heart": { "category": "symbols", "moji": "🖤", + "description": "black heart", "unicodeVersion": "9.0", "digest": "f334679168d6dd7328c28e9ae3cb2b1fca0e9c2777938d586bfe623db2a688b9" }, "black_joker": { "category": "symbols", "moji": "🃏", + "description": "playing card black joker", "unicodeVersion": "6.0", "digest": "d004b25f186494d5b2c65204caa9daecd749c840a0bea5718735e18109e5394d" }, "black_large_square": { "category": "symbols", "moji": "⬛", + "description": "black large square", "unicodeVersion": "5.1", "digest": "cbd90dcbc2f674eafa53820548b5263c18c9845ab39937f085e85aca0aebb479" }, "black_medium_small_square": { "category": "symbols", "moji": "◾", + "description": "black medium small square", "unicodeVersion": "3.2", "digest": "ab38363c2e862b8f67c719397a09a18e1ef996eec190691fdf769f5cfb209660" }, "black_medium_square": { "category": "symbols", "moji": "◼", + "description": "black medium square", "unicodeVersion": "3.2", "digest": "c9ffa87c37e8ee65fadcf755176949901aec7367e02abb85e63cad60cd922116" }, "black_nib": { "category": "objects", "moji": "✒", + "description": "black nib", "unicodeVersion": "1.1", "digest": "58fb23b1155102970eaa23765e7d529a21e8e545e076ec1158bf11b4de5f51a8" }, "black_small_square": { "category": "symbols", "moji": "▪", + "description": "black small square", "unicodeVersion": "1.1", "digest": "f69be6de578fffce5a3e60eda690104b2ef6a855c630040104fb760a02ff1aef" }, "black_square_button": { "category": "symbols", "moji": "🔲", + "description": "black square button", "unicodeVersion": "6.0", "digest": "9d818fcd08ed38cd0bbbcfd83e665aa29b3761c0d8b9806d8954d36785e267a8" }, "blossom": { "category": "nature", "moji": "🌼", + "description": "blossom", "unicodeVersion": "6.0", "digest": "e8cf369d4e4cdb4eccc2ebcbb35439b0344221115701daae642e58dff8544922" }, "blowfish": { "category": "nature", "moji": "🐡", + "description": "blowfish", "unicodeVersion": "6.0", "digest": "e706849ed00f08a82312381c76f6f9ba6cc261fbf87a839c85e7dd54138f9dc3" }, "blue_book": { "category": "objects", "moji": "📘", + "description": "blue book", "unicodeVersion": "6.0", "digest": "4c845748fe890516b32981b0b62bf3e8e9d906840c2060179f4f844100780615" }, "blue_car": { "category": "travel", "moji": "🚙", + "description": "recreational vehicle", "unicodeVersion": "6.0", "digest": "eca91934eb5481726cfd897b1ed5eac306e14d02499fbe49316aaec6c72b6707" }, "blue_heart": { "category": "symbols", "moji": "💙", + "description": "blue heart", "unicodeVersion": "6.0", "digest": "2caa0c8d18538cc871c6fe328a52f71e1df8aabf4d1cc2f5324b261d1b8cb99a" }, "blush": { "category": "people", "moji": "😊", + "description": "smiling face with smiling eyes", "unicodeVersion": "6.0", "digest": "3bfe8d603cfa39999c164779f666d39bbc507f124ba80233ee72da7b3b0c0457" }, "boar": { "category": "nature", "moji": "🐗", + "description": "boar", "unicodeVersion": "6.0", "digest": "c9d67479cace427ac3c30460fcffa1bf9a8e5262c0390962405dbbe6bf830fa6" }, "bomb": { "category": "objects", "moji": "💣", + "description": "bomb", "unicodeVersion": "6.0", "digest": "0155559abc4084f80e9b0b2a2091b8710ddd6369993b7fdd0685f4f8c2fd7e6c" }, "book": { "category": "objects", "moji": "📖", + "description": "open book", "unicodeVersion": "6.0", "digest": "9d912a9d1bb10dc7f2645b345ed09e90461e83df0de275acb806f1f75cef1fcf" }, "bookmark": { "category": "objects", "moji": "🔖", + "description": "bookmark", "unicodeVersion": "6.0", "digest": "5705e3108259d6900649157843c50e22d0086c3630b291d3f942da1a736e3e3d" }, "bookmark_tabs": { "category": "objects", "moji": "📑", + "description": "bookmark tabs", "unicodeVersion": "6.0", "digest": "c8fc7c9f3f82e1ccc97fc591345fdd88b09eec0fca428d8d4632a121cf1bc39a" }, "books": { "category": "objects", "moji": "📚", + "description": "books", "unicodeVersion": "6.0", "digest": "cbcf55d39dd05d26ef7350bc51e0e2f064f78bb8f59d407b516d63f68558f8e4" }, "boom": { "category": "nature", "moji": "💥", + "description": "collision symbol", "unicodeVersion": "6.0", "digest": "f5400e9583f7f997cd2385f21379f6229424a9b221445bc8f36c0bb64bdb3168" }, "boot": { "category": "people", "moji": "👢", + "description": "womans boots", "unicodeVersion": "6.0", "digest": "b4706ff35909a6fb759a3b8a797e90cb67ffc60e4853386a7d89ace9693a9364" }, "bouquet": { "category": "nature", "moji": "💐", + "description": "bouquet", "unicodeVersion": "6.0", "digest": "b93751a27b40f6185a22b3e8b413f0fe09b6010d1057c672e1a23088e0b8286f" }, "bow": { "category": "people", "moji": "🙇", + "description": "person bowing deeply", "unicodeVersion": "6.0", "digest": "33cd6da4d408f18d98bebc6a277dea8b914150e32ee472586ce3f1eb814462bd" }, "bow_and_arrow": { "category": "activity", "moji": "🏹", + "description": "bow and arrow", "unicodeVersion": "8.0", "digest": "051b4d50ab21a68b8583a6313ec183e3e1e96f493b0f4541fbb888f0b95fdd4d" }, "bow_tone1": { "category": "people", "moji": "🙇🏻", + "description": "person bowing deeply tone 1", "unicodeVersion": "8.0", "digest": "995c8400ad60d5adc66c9ae5e3c0ecf56c48b478ad79418d45b6289933d25bdd" }, "bow_tone2": { "category": "people", "moji": "🙇🏼", + "description": "person bowing deeply tone 2", "unicodeVersion": "8.0", "digest": "af89eec2fccda99d9bdd373b2345595882fee1c0a15d29af9028089e20255325" }, "bow_tone3": { "category": "people", "moji": "🙇🏽", + "description": "person bowing deeply tone 3", "unicodeVersion": "8.0", "digest": "015d8122abdf2d0caa03815545f50fb7a71e05dacd46aaa133cc9ace5192f266" }, "bow_tone4": { "category": "people", "moji": "🙇🏾", + "description": "person bowing deeply tone 4", "unicodeVersion": "8.0", "digest": "e8409096a795b775def654d36aeccb8eb91e83d7d1b32145cd73fd0b7b9e885c" }, "bow_tone5": { "category": "people", "moji": "🙇🏿", + "description": "person bowing deeply tone 5", "unicodeVersion": "8.0", "digest": "d87042cde8dbad9fb1a91a2ec60116e27b4a76388b5779d771a0bbae12a2814d" }, "bowling": { "category": "activity", "moji": "🎳", + "description": "bowling", "unicodeVersion": "6.0", "digest": "737f2cdfa4ac964baade585a39771b18080bd5e9b55c8661d3518f468f344662" }, "boxing_glove": { "category": "activity", "moji": "🥊", + "description": "boxing glove", "unicodeVersion": "9.0", "digest": "c914b2ce45f20afad66ad6f0d1b0750c4469e4f48b686dfc4aad1ec8d289c563" }, "boy": { "category": "people", "moji": "👦", + "description": "boy", "unicodeVersion": "6.0", "digest": "7bc0173d8c88f3f12d41f213f7a3a9f5ebf65efad610fd5a2a31935128a6a6c1" }, "boy_tone1": { "category": "people", "moji": "👦🏻", + "description": "boy tone 1", "unicodeVersion": "8.0", "digest": "c0e2f0483715b239fe145b0056566f7a3a722319d9a87c1e66733dff1916a19f" }, "boy_tone2": { "category": "people", "moji": "👦🏼", + "description": "boy tone 2", "unicodeVersion": "8.0", "digest": "0001d0bd1ff4dbd898604ba965b4039d09667d955bc0349301b992f9ab6dd7fd" }, "boy_tone3": { "category": "people", "moji": "👦🏽", + "description": "boy tone 3", "unicodeVersion": "8.0", "digest": "e0f08755955fd2e0bd1c5d5e84429b2a234b24a744bb50bb9f1148495b2b29f9" }, "boy_tone4": { "category": "people", "moji": "👦🏾", + "description": "boy tone 4", "unicodeVersion": "8.0", "digest": "04b6bfee58a26b1ce2e5b403504a7033aaf395f03f5cd23e824f32c90c395fe6" }, "boy_tone5": { "category": "people", "moji": "👦🏿", + "description": "boy tone 5", "unicodeVersion": "8.0", "digest": "0f76e97237203950da36c737dcc6f56dcd6c123401a8c817a0636376c7f38ef5" }, "bread": { "category": "food", "moji": "🍞", + "description": "bread", "unicodeVersion": "6.0", "digest": "81739830f16f33e6a1dd7cc17c25df207846062bb5167bb8abed7fdd49268b86" }, "bride_with_veil": { "category": "people", "moji": "👰", + "description": "bride with veil", "unicodeVersion": "6.0", "digest": "8e24bd91c3f564cf6148f2b3b4a7d692c11dd059e76a13331fdfb04ae060ea70" }, "bride_with_veil_tone1": { "category": "people", "moji": "👰🏻", + "description": "bride with veil tone 1", "unicodeVersion": "8.0", "digest": "0bd2f16f72586f50e768b14b9b353f2e98ccbb2581a568c33b06be56e70ca063" }, "bride_with_veil_tone2": { "category": "people", "moji": "👰🏼", + "description": "bride with veil tone 2", "unicodeVersion": "8.0", "digest": "e5463f811b2075754f0718b891757cd2e81071edf7af2215581227e1aad1d068" }, "bride_with_veil_tone3": { "category": "people", "moji": "👰🏽", + "description": "bride with veil tone 3", "unicodeVersion": "8.0", "digest": "e5a053a26f7ccebae7eb12f638be5ed80f77b744708d783eab2eb8aa091cf516" }, "bride_with_veil_tone4": { "category": "people", "moji": "👰🏾", + "description": "bride with veil tone 4", "unicodeVersion": "8.0", "digest": "410e23825e4401460946dc67a618bd3ace6e1a7c07dd88580a2349423685261f" }, "bride_with_veil_tone5": { "category": "people", "moji": "👰🏿", + "description": "bride with veil tone 5", "unicodeVersion": "8.0", "digest": "454e87e5a74e13e5b4993541231516fbbe6dbe9f990e1a6f3f4a744d7d4c1615" }, "bridge_at_night": { "category": "travel", "moji": "🌉", + "description": "bridge at night", "unicodeVersion": "6.0", "digest": "9d3cda5a59e27e3c90939f1ddbe7e998b3ea4fcacfa1467dea0edf39613c2d7f" }, "briefcase": { "category": "people", "moji": "💼", + "description": "briefcase", "unicodeVersion": "6.0", "digest": "9d00d6a92632aaadc71b017f448c883b27eb31a7554ebb51f7e3a9841f0f7f2b" }, "broken_heart": { "category": "symbols", "moji": "💔", + "description": "broken heart", "unicodeVersion": "6.0", "digest": "c7ca53f444d72e596af46b61ffbc9e7c18a645020c22691e44f967db98dbf853" }, "bug": { "category": "nature", "moji": "🐛", + "description": "bug", "unicodeVersion": "6.0", "digest": "0dccb1d5eb91769377b4c5b310f007b60f54a5c48ba9e467b3a06898a4831b90" }, "bulb": { "category": "objects", "moji": "💡", + "description": "electric light bulb", "unicodeVersion": "6.0", "digest": "ccdaa2dfde5a88a347035a94b9d4d86cfc335ce0a73292423f5788a4bd21a5a8" }, "bullettrain_front": { "category": "travel", "moji": "🚅", + "description": "high-speed train with bullet nose", "unicodeVersion": "6.0", "digest": "5195a6a6d23f28e1aa5ebac6ede0f6c6a8b7ff33a9edf034814f227fe976177a" }, "bullettrain_side": { "category": "travel", "moji": "🚄", + "description": "high-speed train", "unicodeVersion": "6.0", "digest": "96e74842e919716b7bbbab57339bfd70f099a9bcb4710dffd7c80cf38a7bbff7" }, "burrito": { "category": "food", "moji": "🌯", + "description": "burrito", "unicodeVersion": "8.0", "digest": "b2cf81f1efdf87e674461f73f67cd4b58a5f695e65598d0dd3899f2597da43cf" }, "bus": { "category": "travel", "moji": "🚌", + "description": "bus", "unicodeVersion": "6.0", "digest": "192850b762edad21ac8770df38b9cae6d2bc1697a838462f3e36066bfb4eee50" }, "busstop": { "category": "travel", "moji": "🚏", + "description": "bus stop", "unicodeVersion": "6.0", "digest": "adabb1ec36402b33feb636eae3656e5a8b51ff1071bcb14125d8ab80d6d12d2a" }, "bust_in_silhouette": { "category": "people", "moji": "👤", + "description": "bust in silhouette", "unicodeVersion": "6.0", "digest": "277ae43301f1e49e0be03c8e52f0dc7b70c67f9d146bca0a14172e0098f115e6" }, "busts_in_silhouette": { "category": "people", "moji": "👥", + "description": "busts in silhouette", "unicodeVersion": "6.0", "digest": "7fee96f1b68bb2c6002e47f2ed13c06baa6a3168441b9aca572db7ec45612f7b" }, "butterfly": { "category": "nature", "moji": "🦋", + "description": "butterfly", "unicodeVersion": "9.0", "digest": "a91b6598c17b44a8dc8935a1d99e25f4483ea41470cdd2da343039a9eec29ef1" }, "cactus": { "category": "nature", "moji": "🌵", + "description": "cactus", "unicodeVersion": "6.0", "digest": "2c5c4c35f26c7046fdc002b337e0d939729b33a26980e675950f9934c91e40fd" }, "cake": { "category": "food", "moji": "🍰", + "description": "shortcake", "unicodeVersion": "6.0", "digest": "b928902df8084210d51c1da36f9119164a325393c391b28cd8ea914e0b95c17b" }, "calendar": { "category": "objects", "moji": "📆", + "description": "tear-off calendar", "unicodeVersion": "6.0", "digest": "9d990be27778daab041a3583edbd8f83fc8957e42a3aec729c0e2e224a8d05e3" }, "calendar_spiral": { "category": "objects", "moji": "🗓", + "description": "spiral calendar pad", "unicodeVersion": "7.0", "digest": "441a0750eade7ce33e28e58bec76958990c412b68409fcdde59ebad1f25361bb" }, "call_me": { "category": "people", "moji": "🤙", + "description": "call me hand", "unicodeVersion": "9.0", "digest": "83d2ed96dcb8b4adf4f4d030ffd07e25ca16351e1a4fbefdf9f46f5ca496a55f" }, "call_me_tone1": { "category": "people", "moji": "🤙🏻", + "description": "call me hand tone 1", "unicodeVersion": "9.0", "digest": "4a5748efa83e7294e8338b8795d4d315ff1cd31ead6759004d0eb330e50de8cd" }, "call_me_tone2": { "category": "people", "moji": "🤙🏼", + "description": "call me hand tone 2", "unicodeVersion": "9.0", "digest": "54feaa6e3c5789ae6e15622127f0e0213234b4b886e1588ce95814348b1f1519" }, "call_me_tone3": { "category": "people", "moji": "🤙🏽", + "description": "call me hand tone 3", "unicodeVersion": "9.0", "digest": "57e949b951e14843b712dab5a828f915ee255f5bb973db33946aab4057427419" }, "call_me_tone4": { "category": "people", "moji": "🤙🏾", + "description": "call me hand tone 4", "unicodeVersion": "9.0", "digest": "f7787e933978a09c7b8ab8d3b1e1ab395aaae998c455e93bb3db24a4c8a60fe0" }, "call_me_tone5": { "category": "people", "moji": "🤙🏿", + "description": "call me hand tone 5", "unicodeVersion": "9.0", "digest": "1fdb7d833d000b117d20d48142d3026a61cc9c8b712ebb498fa66bf75c74d7a5" }, "calling": { "category": "objects", "moji": "📲", + "description": "mobile phone with rightwards arrow at left", "unicodeVersion": "6.0", "digest": "acf668c75c11c36686005788266524a972fa1c5bcf666ff3403d909edc5cee91" }, "camel": { "category": "nature", "moji": "🐫", + "description": "bactrian camel", "unicodeVersion": "6.0", "digest": "5f927927a7ab1277d0dc8b8211436957968b1e11365a8bf535e9bb94f92c5631" }, "camera": { "category": "objects", "moji": "📷", + "description": "camera", "unicodeVersion": "6.0", "digest": "fde03e396822a36cd6ae756ede885b945a074395264162731ca5db47a3b39d80" }, "camera_with_flash": { "category": "objects", "moji": "📸", + "description": "camera with flash", "unicodeVersion": "7.0", "digest": "9afd380208187780f00244c45d4db6c5ea1ea088d4a1bd8fc92a8f3877149750" }, "camping": { "category": "travel", "moji": "🏕", + "description": "camping", "unicodeVersion": "7.0", "digest": "a42a4ff9521affa72db7b0f01da169b4cb6afb9db1c5dfad47dd4c507bfc30d9" }, "cancer": { "category": "symbols", "moji": "♋", + "description": "cancer", "unicodeVersion": "1.1", "digest": "528c6f21df99a756b553d93a7f395b0f662b30a323affd05f0cedee8ff7b41d6" }, "candle": { "category": "objects", "moji": "🕯", + "description": "candle", "unicodeVersion": "7.0", "digest": "211c04dc3a91b071c284d4180ed09f9d3320e3fd6ba8a9fddd0677bc97fd12cb" }, "candy": { "category": "food", "moji": "🍬", + "description": "candy", "unicodeVersion": "6.0", "digest": "9cff4538918f60f770fceb96e964f5dc3ce31fd08ddd2ab3bfdf2981bfa74100" }, "canoe": { "category": "travel", "moji": "🛶", + "description": "canoe", "unicodeVersion": "9.0", "digest": "56ca308cc2ad4827468cf58c4ccf6ef6b3382835a91e935540a2b973e01d2572" }, "capital_abcd": { "category": "symbols", "moji": "🔠", + "description": "input symbol for latin capital letters", "unicodeVersion": "6.0", "digest": "a416d0b3f564037b680f801fb773b6eaf67225e2cbbfd2cb8a5db0de044321fa" }, "capricorn": { "category": "symbols", "moji": "♑", + "description": "capricorn", "unicodeVersion": "1.1", "digest": "f11abad102603737b55486fe2ea4d01f28b203394bcd84f19a7948156e6c4b96" }, "card_box": { "category": "objects", "moji": "🗃", + "description": "card file box", "unicodeVersion": "7.0", "digest": "7a6199d562f30e02ed31094de6aebeb99eae8ac156f6910463dfed73256f4c9a" }, "card_index": { "category": "objects", "moji": "📇", + "description": "card index", "unicodeVersion": "6.0", "digest": "86e187e0a72ca5d00207d6ef34d66ce15046848a831c2b5184fb840c5332a2a8" }, "carousel_horse": { "category": "travel", "moji": "🎠", + "description": "carousel horse", "unicodeVersion": "6.0", "digest": "c0e7059efc39a64233f774c02ddb1ab51888fff180f906ce13a6e4f9509672fe" }, "carrot": { "category": "food", "moji": "🥕", + "description": "carrot", "unicodeVersion": "9.0", "digest": "3a6fd98b63ee73d982a9cdacb08cf7b4014368cde8ffce6056b7df25a5a472b1" }, "cartwheel": { "category": "activity", "moji": "🤸", + "description": "person doing cartwheel", "unicodeVersion": "9.0", "digest": "d78de3435e0b04a9b1a1048ae12e63e3248f9ace3a0db4d3bda584f22af18863" }, "cartwheel_tone1": { "category": "activity", "moji": "🤸🏻", + "description": "person doing cartwheel tone 1", "unicodeVersion": "9.0", "digest": "39a49781a269bb40d8efc8fd73c973b00fb2e192850ea6073062b5dea0cd5b74" }, "cartwheel_tone2": { "category": "activity", "moji": "🤸🏼", + "description": "person doing cartwheel tone 2", "unicodeVersion": "9.0", "digest": "6231eb35be45457fd648f8f4b79983f03705c9d983a18067f7e6d9ae47bc1958" }, "cartwheel_tone3": { "category": "activity", "moji": "🤸🏽", + "description": "person doing cartwheel tone 3", "unicodeVersion": "9.0", "digest": "ca483c78cc823811a8c279c501d9b283e4c990dafc5995ad40e68ecb0af554df" }, "cartwheel_tone4": { "category": "activity", "moji": "🤸🏾,", + "description": "person doing cartwheel tone 4", "unicodeVersion": "9.0", "digest": "8253afb672431c84e498014c30babb00b9284bec773009e79f7f06aa7108643e" }, "cartwheel_tone5": { "category": "activity", "moji": "🤸🏿", + "description": "person doing cartwheel tone 5", "unicodeVersion": "9.0", "digest": "6fd92baff57c38b3adb6753d9e7e547e762971a8872fd3f1e71c6aaf0b1d3ab9" }, "cat": { "category": "nature", "moji": "🐱", + "description": "cat face", "unicodeVersion": "6.0", "digest": "e52d0d3a205a0ba99094717e171a7f572b713a0e21b276ffa4a826596fe5cafc" }, "cat2": { "category": "nature", "moji": "🐈", + "description": "cat", "unicodeVersion": "6.0", "digest": "46aa67a99f782935932c77b8de93287142297abe52928c173191cf55bb8f4339" }, "cd": { "category": "objects", "moji": "💿", + "description": "optical disc", "unicodeVersion": "6.0", "digest": "16363d8a34b873c12df6354b99f575cae3d80e0d27100ed7eea70f0310953c7b" }, "chains": { "category": "objects", "moji": "⛓", + "description": "chains", "unicodeVersion": "5.2", "digest": "3884cdbc6f2b433062af06f942552e563231c24727a2f10fa280b3bb7aa614e2" }, "champagne": { "category": "food", "moji": "🍾", + "description": "bottle with popping cork", "unicodeVersion": "8.0", "digest": "9e6e8987f30a37ae0f3d7dab2f5eeb50aa32b4f31402b29315eb2994afc72457" }, "champagne_glass": { "category": "food", "moji": "🥂", + "description": "clinking glasses", "unicodeVersion": "9.0", "digest": "5a2e4773f7eb126a00122cbfa4dc535da51ce00e0bf0d8d6ff8bab8b3365f8d2" }, "chart": { "category": "symbols", "moji": "💹", + "description": "chart with upwards trend and yen sign", "unicodeVersion": "6.0", "digest": "a092dbc08f925b028286b2b495a5f59033b8537a586a694f46f4c1e7c3a1e27f" }, "chart_with_downwards_trend": { "category": "objects", "moji": "📉", + "description": "chart with downwards trend", "unicodeVersion": "6.0", "digest": "5db7ccbc37665736a9c0b2f50247dcc09e404ec37f39db45b7b8b9464172a18c" }, "chart_with_upwards_trend": { "category": "objects", "moji": "📈", + "description": "chart with upwards trend", "unicodeVersion": "6.0", "digest": "bc4ea250b102fe5c09847e471478aff065ad3df755d9717896d38d887d9c6733" }, "checkered_flag": { "category": "travel", "moji": "🏁", + "description": "chequered flag", "unicodeVersion": "6.0", "digest": "0e77180e0cf9fc87e755a5a42cf23aec6bf30931db41331311e97ba0be178b78" }, "cheese": { "category": "food", "moji": "🧀", + "description": "cheese wedge", "unicodeVersion": "8.0", "digest": "50a6cb906c2120e2bbc0e22105924262007cfe1554d7b02b8cc84b6adedc6a0b" }, "cherries": { "category": "food", "moji": "🍒", + "description": "cherries", "unicodeVersion": "6.0", "digest": "13b8db9e7e6eec8509aa80c762966e1bf3538fcb1ac3d6eab18ee4da1528cf84" }, "cherry_blossom": { "category": "nature", "moji": "🌸", + "description": "cherry blossom", "unicodeVersion": "6.0", "digest": "af3083f5f8dd94936113f2e16caba5aec7a774d5589aa08bf5de82a2d278cc66" }, "chestnut": { "category": "nature", "moji": "🌰", + "description": "chestnut", "unicodeVersion": "6.0", "digest": "9f85b79b207a69ab81ab88dcef04954000965b039b4cf57de5f1b381745ab98b" }, "chicken": { "category": "nature", "moji": "🐔", + "description": "chicken", "unicodeVersion": "6.0", "digest": "57ceb4459d183740009caac6ebed089d2f1e12f67c138e1be1d0f992313c0ac4" }, "children_crossing": { "category": "symbols", "moji": "🚸", + "description": "children crossing", "unicodeVersion": "6.0", "digest": "0ded7d9aca0161e8ef8e2858c3c198e70e4badc7105ac3a6886e06975de19106" }, "chipmunk": { "category": "nature", "moji": "🐿", + "description": "chipmunk", "unicodeVersion": "7.0", "digest": "5b0dc1a859163097727ba2ba5ffca38b0a54d925eebb089977d28d0b4d917a3f" }, "chocolate_bar": { "category": "food", "moji": "🍫", + "description": "chocolate bar", "unicodeVersion": "6.0", "digest": "dd273e5050488acaf885f8a18b6e2b3901f69c5b39fa6465fb60621783d4109a" }, "christmas_tree": { "category": "nature", "moji": "🎄", + "description": "christmas tree", "unicodeVersion": "6.0", "digest": "ce60cbe2ebbe8057be8edea2392455fedd2bcda64a0a831f6a1942028af7e747" }, "church": { "category": "travel", "moji": "⛪", + "description": "church", "unicodeVersion": "5.2", "digest": "2c328456528f7336e59443e20ec3ab22fe71f1fccb1dd50d0ad68eb206937557" }, "cinema": { "category": "symbols", "moji": "🎦", + "description": "cinema", "unicodeVersion": "6.0", "digest": "4c26dcdc76f93dbc2a1dc49ed4e132b8e8f2b7cdc1acf5e09b3dfd99430d97cd" }, "circus_tent": { "category": "activity", "moji": "🎪", + "description": "circus tent", "unicodeVersion": "6.0", "digest": "fec5f2a06222be8be549178b29720343cc00145177ec387ca4e6f3432481fe77" }, "city_dusk": { "category": "travel", "moji": "🌆", + "description": "cityscape at dusk", "unicodeVersion": "6.0", "digest": "bba345e949dcc51f5f018220f000223797970c82ead2ab9c822f9dc0847aa155" }, "city_sunset": { "category": "travel", "moji": "🌇", + "description": "sunset over buildings", "unicodeVersion": "6.0", "digest": "a846df1a4c7c778f8e1729804aece86eb29d2fcb95dc39eaaf2aae1897f3dcc7" }, "cityscape": { "category": "travel", "moji": "🏙", + "description": "cityscape", "unicodeVersion": "7.0", "digest": "ee360be7514c4bfb0d539dd28f3b2031ebcef04e850723ec0685fb54bd8e6d5f" }, "cl": { "category": "symbols", "moji": "🆑", + "description": "squared cl", "unicodeVersion": "6.0", "digest": "fcec2855dbad9fda11d6e2802bc0dcaabab0b5be233508f5e439f156f07602c1" }, "clap": { "category": "people", "moji": "👏", + "description": "clapping hands sign", "unicodeVersion": "6.0", "digest": "a1860ce7812a9f6fb55e45761e1b79a2f8f0620eb04f80748a38420889d58a2a" }, "clap_tone1": { "category": "people", "moji": "👏🏻", + "description": "clapping hands sign tone 1", "unicodeVersion": "8.0", "digest": "18a7022e08223fb2109af5a9b9a5b4f47dc870ce4453f4987d2d0b729ef54586" }, "clap_tone2": { "category": "people", "moji": "👏🏼", + "description": "clapping hands sign tone 2", "unicodeVersion": "8.0", "digest": "5954c8658b15e755d2018d8674df84d38e22ffededc4d726c6a33b709f71426a" }, "clap_tone3": { "category": "people", "moji": "👏🏽", + "description": "clapping hands sign tone 3", "unicodeVersion": "8.0", "digest": "22639b6bd3c53784a2f855d6db7bdf31621519f19dfc29a6bc310eee6421f742" }, "clap_tone4": { "category": "people", "moji": "👏🏾", + "description": "clapping hands sign tone 4", "unicodeVersion": "8.0", "digest": "e55248dc163d1bbd118b50cd8767750ead86d082151febbc0a75b32d63abceec" }, "clap_tone5": { "category": "people", "moji": "👏🏿", + "description": "clapping hands sign tone 5", "unicodeVersion": "8.0", "digest": "76046b8157dabbe048a07fc318122456020c9c980fc1b8ab76802330e07b3b53" }, "clapper": { "category": "activity", "moji": "🎬", + "description": "clapper board", "unicodeVersion": "6.0", "digest": "8149752a0e3e8abede2d433d1afab6d217877d0c76adb1e2845a0142c0cdcbaa" }, "classical_building": { "category": "travel", "moji": "🏛", + "description": "classical building", "unicodeVersion": "7.0", "digest": "9ee0d00c43d6e22b6a3ddea67619737270cc7e9294797a19c7c60d5f92aa44fa" }, "clipboard": { "category": "objects", "moji": "📋", + "description": "clipboard", "unicodeVersion": "6.0", "digest": "bdd7f7d973c714e59d2903d401a876e6018794c7987c9ca57108c137c5edc25f" }, "clock": { "category": "objects", "moji": "🕰", + "description": "mantlepiece clock", "unicodeVersion": "7.0", "digest": "302835eab2637db799acf69b3d795571ef3432251267050db0704f2954e8b190" }, "clock1": { "category": "symbols", "moji": "🕐", + "description": "clock face one oclock", "unicodeVersion": "6.0", "digest": "1778eec07ce061c9393e5abee5ca83b24e1ce61d8a75fa2e39efcb31aa160395" }, "clock10": { "category": "symbols", "moji": "🕙", + "description": "clock face ten oclock", "unicodeVersion": "6.0", "digest": "601fc12ea5280a54c2e69dbb685f454e4165fe771756ed6f89016e29e683a24f" }, "clock1030": { "category": "symbols", "moji": "🕥", + "description": "clock face ten-thirty", "unicodeVersion": "6.0", "digest": "4fd155f08f797542d52cff4b0aa3ca9f080f37a41c301b82f90ff6d4693c890e" }, "clock11": { "category": "symbols", "moji": "🕚", + "description": "clock face eleven oclock", "unicodeVersion": "6.0", "digest": "5c79dc812e812e8a01993ea633b323d654ce3a7ea258692781a4896e4ad2017e" }, "clock1130": { "category": "symbols", "moji": "🕦", + "description": "clock face eleven-thirty", "unicodeVersion": "6.0", "digest": "41497ee2020ee5ac9aa5f9b07560f7afca7c422b04214449cfc5cea9f020f52e" }, "clock12": { "category": "symbols", "moji": "🕛", + "description": "clock face twelve oclock", "unicodeVersion": "6.0", "digest": "046bb7ffa5f5d27c2e3411ba543484d9dabb8ebf6d6e7a7e9bfb088c1813500c" }, "clock1230": { "category": "symbols", "moji": "🕧", + "description": "clock face twelve-thirty", "unicodeVersion": "6.0", "digest": "bbfe9db5a2043aaba19a7a2a0185c7efcebf1e8c9263b8233f75b53c4825f0f4" }, "clock130": { "category": "symbols", "moji": "🕜", + "description": "clock face one-thirty", "unicodeVersion": "6.0", "digest": "8662cb395ee680c2781123305c4c8ce8c0df9565c2c942668940be540cc0c094" }, "clock2": { "category": "symbols", "moji": "🕑", + "description": "clock face two oclock", "unicodeVersion": "6.0", "digest": "42f7429748b612dce7de77221cbbc710655811f7bb23e2a986c36e6d662f0ec4" }, "clock230": { "category": "symbols", "moji": "🕝", + "description": "clock face two-thirty", "unicodeVersion": "6.0", "digest": "e710b6ef14227cd240ea3e2a867c8ef45b5c060adf3cb30ba9077c2351fe6677" }, "clock3": { "category": "symbols", "moji": "🕒", + "description": "clock face three oclock", "unicodeVersion": "6.0", "digest": "7340d465b398a378211dff9ec806db579d061206fd6fc238623d070cfe0a55ce" }, "clock330": { "category": "symbols", "moji": "🕞", + "description": "clock face three-thirty", "unicodeVersion": "6.0", "digest": "7aa4a15cc8de04ed3bdeb0f8a54a7915065f2809a07054e002d89926c9766831" }, "clock4": { "category": "symbols", "moji": "🕓", + "description": "clock face four oclock", "unicodeVersion": "6.0", "digest": "36fd88e81ad488b0ec49a911a838693281573fa14736ae4a6dd1c40a4ff69bb1" }, "clock430": { "category": "symbols", "moji": "🕟", + "description": "clock face four-thirty", "unicodeVersion": "6.0", "digest": "7bd5dd71e89d95dcf18b9e8c1fe2a353a7da3b69aadb8dda80ee9bafb05da58d" }, "clock5": { "category": "symbols", "moji": "🕔", + "description": "clock face five oclock", "unicodeVersion": "6.0", "digest": "aa406409e56a0bfd8c850e44efe45fd190ffd7bf7061e934ed7928dfbdfc9eba" }, "clock530": { "category": "symbols", "moji": "🕠", + "description": "clock face five-thirty", "unicodeVersion": "6.0", "digest": "25dd3bcc53ddd98eeea498d7dbd4c306ef39dd033f15909063388a0800febf41" }, "clock6": { "category": "symbols", "moji": "🕕", + "description": "clock face six oclock", "unicodeVersion": "6.0", "digest": "0a321eaf1bc5db8436bbadac66c45ba257fc98ad4c7569ce3fc6602c824b6d7c" }, "clock630": { "category": "symbols", "moji": "🕡", + "description": "clock face six-thirty", "unicodeVersion": "6.0", "digest": "55a4c5a665fdd38a724e9357a93c55401fcd5f1b13078c25754bd70c3fc4ccec" }, "clock7": { "category": "symbols", "moji": "🕖", + "description": "clock face seven oclock", "unicodeVersion": "6.0", "digest": "6154306545716e865da0ec537ee4f22bfe6c7294502a64a2dcf425c587d0e2a2" }, "clock730": { "category": "symbols", "moji": "🕢", + "description": "clock face seven-thirty", "unicodeVersion": "6.0", "digest": "6925654de642e50f84661f94364a96c87757d73fffe766aacbf4bbd70130547b" }, "clock8": { "category": "symbols", "moji": "🕗", + "description": "clock face eight oclock", "unicodeVersion": "6.0", "digest": "9be2d189c7ea56d39fd259f84853d753c1cf33e64f8ed57f86f822d9ae23a1ee" }, "clock830": { "category": "symbols", "moji": "🕣", + "description": "clock face eight-thirty", "unicodeVersion": "6.0", "digest": "16878613c0000d2f558c88d080551f424a8bd9df1358e0f931dd25c3da68f2d9" }, "clock9": { "category": "symbols", "moji": "🕘", + "description": "clock face nine oclock", "unicodeVersion": "6.0", "digest": "1d1e7e3c9d085ffa5b7c0f3d9fd394b734f16ae3b60df09af50fe6c8d4f3c8bb" }, "clock930": { "category": "symbols", "moji": "🕤", + "description": "clock face nine-thirty", "unicodeVersion": "6.0", "digest": "9fdef6a4939315c017b165e1dbac7710fb335df8c309be3fe2a011ef7fc28d74" }, "closed_book": { "category": "objects", "moji": "📕", + "description": "closed book", "unicodeVersion": "6.0", "digest": "b18288629d201bfdfc5d66ec47df89809d00642b15732757e6a04789f36a7d9f" }, "closed_lock_with_key": { "category": "objects", "moji": "🔐", + "description": "closed lock with key", "unicodeVersion": "6.0", "digest": "e39adfe9b30973bca16472c2b7e6462b064a93b9d452aa48edd74c727641a83d" }, "closed_umbrella": { "category": "people", "moji": "🌂", + "description": "closed umbrella", "unicodeVersion": "6.0", "digest": "2cc0592c74601f7439e88c3c1ec4f05e3459608ef1ea6558c5824ed7c3889727" }, "cloud": { "category": "nature", "moji": "☁", + "description": "cloud", "unicodeVersion": "1.1", "digest": "5b3a19718dfa8a381929665afdc2284464d24020c8dd0caff4dad465a1f536ba" }, "cloud_lightning": { "category": "nature", "moji": "🌩", + "description": "cloud with lightning", "unicodeVersion": "7.0", "digest": "2b32f6d87726df2935ad81870879ccec30ce9b4fd5861d1a6317f9eca2f013d9" }, "cloud_rain": { "category": "nature", "moji": "🌧", + "description": "cloud with rain", "unicodeVersion": "7.0", "digest": "1e1e8bc59e168e1d2e72bf11f2d43cb578cbf0a5f1daf383bba5c56fb750ee71" }, "cloud_snow": { "category": "nature", "moji": "🌨", + "description": "cloud with snow", "unicodeVersion": "7.0", "digest": "2d364f859b83e684213e8eece1640208d80a8de0a49d0fc8e0e24c5a8493a3b1" }, "cloud_tornado": { "category": "nature", "moji": "🌪", + "description": "cloud with tornado", "unicodeVersion": "7.0", "digest": "7cbed2343c280ba3996082b3d0fb9d8cd57d6e62fe6c9ecb159f46b4a2e49151" }, "clown": { "category": "people", "moji": "🤡", + "description": "clown face", "unicodeVersion": "9.0", "digest": "eea95687caabc9e808514c2450ba599e5e24ef47923dbec86f5297a64438e2e5" }, "clubs": { "category": "symbols", "moji": "♣", + "description": "black club suit", "unicodeVersion": "1.1", "digest": "b8cf72ecd8568ced077b475d94788fb282bdb06d25031b5d54dd63e25effb138" }, "cocktail": { "category": "food", "moji": "🍸", + "description": "cocktail glass", "unicodeVersion": "6.0", "digest": "3792def2cde885cf32167f04904d3b0b788388e8af410c63e4cd31550feba775" }, "coffee": { "category": "food", "moji": "☕", + "description": "hot beverage", "unicodeVersion": "4.0", "digest": "0d29615a7a67d3aafa257b909bb915dc74fa8f854acb0d9a2c29e94eedf80326" }, "coffin": { "category": "objects", "moji": "⚰", + "description": "coffin", "unicodeVersion": "4.1", "digest": "78eccc1aad2a822649fba8503d4d30354bef367c4271193c40ddb692308f9db8" }, "cold_sweat": { "category": "people", "moji": "😰", + "description": "face with open mouth and cold sweat", "unicodeVersion": "6.0", "digest": "f53aab523ed3fa2224a16881d263fb5e039f163380f92feb2c63c20f9b14dcd2" }, "comet": { "category": "nature", "moji": "☄", + "description": "comet", "unicodeVersion": "1.1", "digest": "40ce93e55c6e57a88d80670b37171190bd5ffc87b7078891d8de5b15795385c5" }, "compression": { "category": "objects", "moji": "🗜", + "description": "compression", "unicodeVersion": "7.0", "digest": "c8841f7afb5345f1c31da116a7fb41d07232ea58d3f7f1a75c5890aa1a80bfd6" }, "computer": { "category": "objects", "moji": "💻", + "description": "personal computer", "unicodeVersion": "6.0", "digest": "c970ce76b5607434895b0407bdaa93140f887930781a17dd7dcf16f711451d93" }, "confetti_ball": { "category": "objects", "moji": "🎊", + "description": "confetti ball", "unicodeVersion": "6.0", "digest": "a638b16f1acdbcf69edf760161b1bd7ff1fd5426c5b1203ad9d294dcc0701f10" }, "confounded": { "category": "people", "moji": "😖", + "description": "confounded face", "unicodeVersion": "6.0", "digest": "e2ff3b4df65d00c1ca9ae0cb379f959ea2cecefb3d676d4f8c2c5f2c103da4f6" }, "confused": { "category": "people", "moji": "😕", + "description": "confused face", "unicodeVersion": "6.1", "digest": "118d7f830ec08a3ac4b798eebb77a989b8c142f2588727181be4a2548e3c4f06" }, "congratulations": { "category": "symbols", "moji": "㊗", + "description": "circled ideograph congratulation", "unicodeVersion": "1.1", "digest": "02fd1338c54fe5f9a0fd861f23c56edc1d39bcd3140b68f0f626f9e2494d2d1c" }, "construction": { "category": "travel", "moji": "🚧", + "description": "construction sign", "unicodeVersion": "6.0", "digest": "c3a0401331111b9eda1206bee5f322db80b0870547d307b10dcac1314e4078c8" }, "construction_site": { "category": "travel", "moji": "🏗", + "description": "building construction", "unicodeVersion": "7.0", "digest": "c611f0a5de10f000a0756935f226845c7292f19ff5581d1f7a7554316338bbcb" }, "construction_worker": { "category": "people", "moji": "👷", + "description": "construction worker", "unicodeVersion": "6.0", "digest": "8c094733987e7c4da8d3aa4588b530ae07042bd70cf337b1fd412a70ee8f0ed6" }, "construction_worker_tone1": { "category": "people", "moji": "👷🏻", + "description": "construction worker tone 1", "unicodeVersion": "8.0", "digest": "fcd927405fef4486105cd3aff62155467d21cebbc013924d4b52b717b566602b" }, "construction_worker_tone2": { "category": "people", "moji": "👷🏼", + "description": "construction worker tone 2", "unicodeVersion": "8.0", "digest": "d1ec773828936c703dd6e334e696dc3cf7c34c0a8ec691564a384b735cdeaaba" }, "construction_worker_tone3": { "category": "people", "moji": "👷🏽", + "description": "construction worker tone 3", "unicodeVersion": "8.0", "digest": "37c114d6879b9b32b800b0d4cf770dcbe04d1455698130ecd709a0cb9dea880b" }, "construction_worker_tone4": { "category": "people", "moji": "👷🏾", + "description": "construction worker tone 4", "unicodeVersion": "8.0", "digest": "5264996c1bedb6061a0dfdddce233d863bf308d27127ad152b63bfd983162cf7" }, "construction_worker_tone5": { "category": "people", "moji": "👷🏿", + "description": "construction worker tone 5", "unicodeVersion": "8.0", "digest": "87051aec81fd5dfd4dc44ff0411a528ee08253e9494d37efa550694e28dde6d3" }, "control_knobs": { "category": "objects", "moji": "🎛", + "description": "control knobs", "unicodeVersion": "7.0", "digest": "0d7f33ff7acc1cc3a81e6a786ff007df20da145e3070f338505dfed5100e9fcb" }, "convenience_store": { "category": "travel", "moji": "🏪", + "description": "convenience store", "unicodeVersion": "6.0", "digest": "975dcf9b8e9e3fb1e29574b41300b9d96fd64703b3c18ff52f9f1875d1cf1b52" }, "cookie": { "category": "food", "moji": "🍪", + "description": "cookie", "unicodeVersion": "6.0", "digest": "4bed3522bd50091ac5b68ca760661eb484d7f1b9c9d564d2097bd812b7f28ae4" }, "cooking": { "category": "food", "moji": "🍳", + "description": "cooking", "unicodeVersion": "6.0", "digest": "563ffd6cae381ce1e318cdacc54e70040d6a01a50d0db8aeb50edbbe413eac58" }, "cool": { "category": "symbols", "moji": "🆒", + "description": "squared cool", "unicodeVersion": "6.0", "digest": "5739a37341c782a4736adfce804e12776ae33081098a3d052d8ae9a64b4d22d1" }, "cop": { "category": "people", "moji": "👮", + "description": "police officer", "unicodeVersion": "6.0", "digest": "78996521bbe231d03ebea355226d8a1515f47cde7b2fbeca1037e7b7e5133466" }, "cop_tone1": { "category": "people", "moji": "👮🏻", + "description": "police officer tone 1", "unicodeVersion": "8.0", "digest": "8a38cd107f5f4c0b821ac43f32df5dc57facaf39fbafb98483ec00fd7df41baf" }, "cop_tone2": { "category": "people", "moji": "👮🏼", + "description": "police officer tone 2", "unicodeVersion": "8.0", "digest": "8ab8ab086f3ff82aa4bf4760c3c822846ec2696c41d21dffdac12d5afbe398b7" }, "cop_tone3": { "category": "people", "moji": "👮🏽", + "description": "police officer tone 3", "unicodeVersion": "8.0", "digest": "fce710a99fd44a7c8af3ea01b2007e46d3ff38d7a0dff1ef26d6f893ede7e6d2" }, "cop_tone4": { "category": "people", "moji": "👮🏾", + "description": "police officer tone 4", "unicodeVersion": "8.0", "digest": "3017dd73ef475379911c5e6c79bd0f9f533dbbc5057bce6a11244faa12996ba0" }, "cop_tone5": { "category": "people", "moji": "👮🏿", + "description": "police officer tone 5", "unicodeVersion": "8.0", "digest": "a3b8807b3f2a8d6ee9bcec0339355bda486e8c930f727139f5447a4b046a6307" }, "copyright": { "category": "symbols", "moji": "©", + "description": "copyright sign", "unicodeVersion": "1.1", "digest": "cc28663cdd3f8333d9bb57b511348cde4e51bda19cf0629dccb05c8fc425e079" }, "corn": { "category": "food", "moji": "🌽", + "description": "ear of maize", "unicodeVersion": "6.0", "digest": "a099a0b291fa758690e6ee6c762b9ade9a0e3350a707c52d968dfffbcc467de5" }, "couch": { "category": "objects", "moji": "🛋", + "description": "couch and lamp", "unicodeVersion": "7.0", "digest": "84cd734dbaa7f9f519438036d687e7a53217130779bc3de30258f163521b9474" }, "couple": { "category": "people", "moji": "👫", + "description": "man and woman holding hands", "unicodeVersion": "6.0", "digest": "c897ba76e24e2f43a4aa261c2754800a8473f43c7ce53f9909a6af2c4897732a" }, "couple_mm": { "category": "people", "moji": "👨❤️👨", + "description": "couple (man,man)", "unicodeVersion": "6.0", "digest": "c812471d35d46e12270653039a907d1dfa2dea0defd65596283e5b8e03cea803" }, "couple_with_heart": { "category": "people", "moji": "💑", + "description": "couple with heart", "unicodeVersion": "6.0", "digest": "420bfa81bad10365550c77a98e1c07eb00d03663fe7b610fab1aca8a0a9d201b" }, "couple_ww": { "category": "people", "moji": "👩❤️👩", + "description": "couple (woman,woman)", "unicodeVersion": "6.0", "digest": "7ac49153a612d63302299eee996308b7dcafa0a152473dab679215036fe6567e" }, "couplekiss": { "category": "people", "moji": "💏", + "description": "kiss", "unicodeVersion": "6.0", "digest": "1acfef9d375c4c1deb235babd856b0f90ad4f3194751694cb6abb44f00f29e42" }, "cow": { "category": "nature", "moji": "🐮", + "description": "cow face", "unicodeVersion": "6.0", "digest": "d71c854ff8b343ee24b8c2b9d56c7cb3fc6fa1a6dc0d7a137841b9f646e6d71b" }, "cow2": { "category": "nature", "moji": "🐄", + "description": "cow", "unicodeVersion": "6.0", "digest": "e7a5131d7dee0f3356814b0ac1ea8ff280b12a7b580181e20ddb0b7eeb7e7339" }, "cowboy": { "category": "people", "moji": "🤠", + "description": "face with cowboy hat", "unicodeVersion": "9.0", "digest": "1aabf23f6b95a9b772fdb8eb45b8ec93584a5357f9131c6eabc9d1b83fe67e89" }, "crab": { "category": "nature", "moji": "🦀", + "description": "crab", "unicodeVersion": "8.0", "digest": "e6be16699fdb5d87f42f28f6cc141a44b7ffd834ecdd536813c4b5b86d3fc4a5" }, "crayon": { "category": "objects", "moji": "🖍", + "description": "lower left crayon", "unicodeVersion": "7.0", "digest": "b180d6afa4777861222a4228164ce284230fe90c589f52ffa9351bac777e901a" }, "credit_card": { "category": "objects", "moji": "💳", + "description": "credit card", "unicodeVersion": "6.0", "digest": "808cd120fd3738eb2be1f6c6c029d98387b0e03fca7d1451e8fbf9c5ab3f643f" }, "crescent_moon": { "category": "nature", "moji": "🌙", + "description": "crescent moon", "unicodeVersion": "6.0", "digest": "042e7e01e6e88b97a763b7cc41e2a2b3fe68a649bacf4a090cd28fc653baf640" }, "cricket": { "category": "activity", "moji": "🏏", + "description": "cricket bat and ball", "unicodeVersion": "8.0", "digest": "4c4559d0b4efe24cc248fa57f413541307992e519d0cb9fb8828637ac2f4cc16" }, "crocodile": { "category": "nature", "moji": "🐊", + "description": "crocodile", "unicodeVersion": "6.0", "digest": "59cb4164c50b6bc9ae311ce6f7610467c1aaafa848b5fff7614f064715f91992" }, "croissant": { "category": "food", "moji": "🥐", + "description": "croissant", "unicodeVersion": "9.0", "digest": "b751e287157a1e276617a841a5b5f7f1208ca226cfd8fa947f144390b65a5e16" }, "cross": { "category": "symbols", "moji": "✝", + "description": "latin cross", "unicodeVersion": "1.1", "digest": "a6b07c838fb75ef2ebefa2df6005e8d784753239ec03c37695a13e3b1954d653" }, "crossed_flags": { "category": "objects", "moji": "🎌", + "description": "crossed flags", "unicodeVersion": "6.0", "digest": "2841c671075e6f1a79c61c2d716423159fb0bc0786e3fb0049697766533bf262" }, "crossed_swords": { "category": "objects", "moji": "⚔", + "description": "crossed swords", "unicodeVersion": "4.1", "digest": "3771a5b26b514236521ce44e15f7730fa9148c6a782b9b600ab870a1f7de6f9f" }, "crown": { "category": "people", "moji": "👑", + "description": "crown", "unicodeVersion": "6.0", "digest": "6741e58d8f823194e0a3484ac1563e20d9e0b44c1bc46d82444dfffa092cdfc7" }, "cruise_ship": { "category": "travel", "moji": "🛳", + "description": "passenger ship", "unicodeVersion": "7.0", "digest": "2b7b62db5d118a632673564099e3405ea6d61ea9b8e123b5a2aaf011bb2a54a4" }, "cry": { "category": "people", "moji": "😢", + "description": "crying face", "unicodeVersion": "6.0", "digest": "fc3307ec4fe75539770c1123a0e8e721d9e021009a502655132f68d7cc453816" }, "crying_cat_face": { "category": "people", "moji": "😿", + "description": "crying cat face", "unicodeVersion": "6.0", "digest": "4942c24935c22babdcb8af41d2c0a7588356b6b674bc238902e2f10ad03e2c5b" }, "crystal_ball": { "category": "objects", "moji": "🔮", + "description": "crystal ball", "unicodeVersion": "6.0", "digest": "05f73b30b1e5b0fc66fb5dc6caddd2d547ee7b9d2f97513dc908ba1a2e352e30" }, "cucumber": { "category": "food", "moji": "🥒", + "description": "cucumber", "unicodeVersion": "9.0", "digest": "d1196e23f2f155ef5c1330f8497f40957a7357cb177127f457c5c471f0a23727" }, "cupid": { "category": "symbols", "moji": "💘", + "description": "heart with arrow", "unicodeVersion": "6.0", "digest": "246e71f44c6ebc2e4f887e25438e4f894e8cc92e06069e711b893ff391abb658" }, "curly_loop": { "category": "symbols", "moji": "➰", + "description": "curly loop", "unicodeVersion": "6.0", "digest": "9e4eb98d6597888f91208080c6a79824adb432ea34f46c85da26cb630bd1cc73" }, "currency_exchange": { "category": "symbols", "moji": "💱", + "description": "currency exchange", "unicodeVersion": "6.0", "digest": "b85377265b9876888969aa42b65bba0be523a370175baf226f20131e535af554" }, "curry": { "category": "food", "moji": "🍛", + "description": "curry and rice", "unicodeVersion": "6.0", "digest": "a01c0a713662817720b485f7739f57e61afc025f5c43792f4de961c94f92f31e" }, "custard": { "category": "food", "moji": "🍮", + "description": "custard", "unicodeVersion": "6.0", "digest": "85c2b9ac904134a6c3587eb0a0806f2ab4282c5ed5c79d41734f3203998f757e" }, "customs": { "category": "symbols", "moji": "🛃", + "description": "customs", "unicodeVersion": "6.0", "digest": "eb2546e1e617d4c1a1f614318af5e5dacf3e8d9479ffa08108977defa83ded32" }, "cyclone": { "category": "symbols", "moji": "🌀", + "description": "cyclone", "unicodeVersion": "6.0", "digest": "7a0f8564d76adf2d0ed272f56dc0d01fb7b557852e0ca797e73f5472b8630bf3" }, "dagger": { "category": "objects", "moji": "🗡", + "description": "dagger knife", "unicodeVersion": "7.0", "digest": "35a179168198d03295e626cc27d3b92d30a732c55a2ca75d7a11a0fbed414772" }, "dancer": { "category": "people", "moji": "💃", + "description": "dancer", "unicodeVersion": "6.0", "digest": "66ffa86827e85acae4aa870c0859fe3a9dad03d21ff4bc800b61c95c902a8a90" }, "dancer_tone1": { "category": "people", "moji": "💃🏻", + "description": "dancer tone 1", "unicodeVersion": "8.0", "digest": "bdbee740addc890e369d3469a3585eb0d1e4fbc7e04dd6f6aca762d8aeee6a8c" }, "dancer_tone2": { "category": "people", "moji": "💃🏼", + "description": "dancer tone 2", "unicodeVersion": "8.0", "digest": "9f7b4c627241eaa2def9717a5286a423f0b9c1b044dd9ea4442a76f1858d14a4" }, "dancer_tone3": { "category": "people", "moji": "💃🏽", + "description": "dancer tone 3", "unicodeVersion": "8.0", "digest": "a6bd49a377ce6c2004bf126b6f66d0b21d8c14103c2add7b10f12ed9e1c2d302" }, "dancer_tone4": { "category": "people", "moji": "💃🏾", + "description": "dancer tone 4", "unicodeVersion": "8.0", "digest": "4ec2a7629c01b0e9006b5cda4deae3bf297ce3b71d18063f93eeb5c14be19a1a" }, "dancer_tone5": { "category": "people", "moji": "💃🏿", + "description": "dancer tone 5", "unicodeVersion": "8.0", "digest": "2b48e3a6b366c6f55f73b816e6fb03c39e9890f586f7e9c9043cf0c013d9cdd5" }, "dancers": { "category": "people", "moji": "👯", + "description": "woman with bunny ears", "unicodeVersion": "6.0", "digest": "12be66ed19d232bb387270f40bece68bd0cb2342b318f6c9bb8b49c64ff7d0ad" }, "dango": { "category": "food", "moji": "🍡", + "description": "dango", "unicodeVersion": "6.0", "digest": "34e8cd153c50f2d725abe8934c35c96a3ab533f0cc5fbb1e1474eafad1dc1fc2" }, "dark_sunglasses": { "category": "people", "moji": "🕶", + "description": "dark sunglasses", "unicodeVersion": "7.0", "digest": "d0a735ad5bf0ece00af2a21abf950b89292ebd8ca6e28b1dbb1368252fb44afe" }, "dart": { "category": "activity", "moji": "🎯", + "description": "direct hit", "unicodeVersion": "6.0", "digest": "998642f06a875905e0a6bf30963c025baff1cf55b8e76884b9119f2d71188b0c" }, "dash": { "category": "nature", "moji": "💨", + "description": "dash symbol", "unicodeVersion": "6.0", "digest": "f7aae7d3887c67d76f3329c2dc9e6807dc580a4b07ab35599c7805e41823a345" }, "date": { "category": "objects", "moji": "📅", + "description": "calendar", "unicodeVersion": "6.0", "digest": "d0b695e4a7cfbbe71b4fbebf345b66ca98f0cf1c751362928e54c23ca78d4c7b" }, "deciduous_tree": { "category": "nature", "moji": "🌳", + "description": "deciduous tree", "unicodeVersion": "6.0", "digest": "3c70f1a77f2754f41c830e88d43b7d53c14311d64626ded164aa9ac7d2695790" }, "deer": { "category": "nature", "moji": "🦌", + "description": "deer", "unicodeVersion": "9.0", "digest": "7f4302ca68fd121ee73be48d0a0a0fb9e7e2741071a491ad2b7b0eab9f11ad25" }, "department_store": { "category": "travel", "moji": "🏬", + "description": "department store", "unicodeVersion": "6.0", "digest": "4be910d2efe74d8ce2c1f41d7753c8873579faca83fcf779a4887d8ab9e5923b" }, "desert": { "category": "travel", "moji": "🏜", + "description": "desert", "unicodeVersion": "7.0", "digest": "d4b1a11c5130debe042df6cc2b3389f15c68a5cb32dc1b3a82b78f733d0c9e4e" }, "desktop": { "category": "objects", "moji": "🖥", + "description": "desktop computer", "unicodeVersion": "7.0", "digest": "cde5bfb6c71bb7d663808a3561b24cb5b5560f95f510b40f81250cac1b21933e" }, "diamond_shape_with_a_dot_inside": { "category": "symbols", "moji": "💠", + "description": "diamond shape with a dot inside", "unicodeVersion": "6.0", "digest": "e91323577ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3" }, "diamonds": { "category": "symbols", "moji": "♦", + "description": "black diamond suit", "unicodeVersion": "1.1", "digest": "bf3d9a020afe8aa226db73590bc193a9c2c3e6e642edd2445c5960c3e67cf153" }, "disappointed": { "category": "people", "moji": "😞", + "description": "disappointed face", "unicodeVersion": "6.0", "digest": "c0f406c6beea0fd1328adefc097d04aa16b72f7a5afa0867967d8ea25d72db17" }, "disappointed_relieved": { "category": "people", "moji": "😥", + "description": "disappointed but relieved face", "unicodeVersion": "6.0", "digest": "c826f5dd4f2f7e5289d720851d4826ab8284d915606c1b152ab229b7fadbba14" }, "dividers": { "category": "objects", "moji": "🗂", + "description": "card index dividers", "unicodeVersion": "7.0", "digest": "4b2c653b18cf0fa31f1f0ac94a6fbd214ea0d1b0a90a450ab6e169906fc5764f" }, "dizzy": { "category": "nature", "moji": "💫", + "description": "dizzy symbol", "unicodeVersion": "6.0", "digest": "d577545c2de42389695447c6ebbfef895f30f0fda84eef45684f9bf4a9c27ff1" }, "dizzy_face": { "category": "people", "moji": "😵", + "description": "dizzy face", "unicodeVersion": "6.0", "digest": "7b3aeaffb4e15ccf633b91dda4a44847a1eb28d78ce58b4d171b20a771bde414" }, "do_not_litter": { "category": "symbols", "moji": "🚯", + "description": "do not litter symbol", "unicodeVersion": "6.0", "digest": "98b07fbbcdb438d1b8a755869fa2de8e180a77fce359ec830eb46d38ec3e67cb" }, "dog": { "category": "nature", "moji": "🐶", + "description": "dog face", "unicodeVersion": "6.0", "digest": "3b31ce067b13e463284ce85536512cb1f8cd8b52fe73659f69971d0d6c1dfc11" }, "dog2": { "category": "nature", "moji": "🐕", + "description": "dog", "unicodeVersion": "6.0", "digest": "0a8901bce5ed994533ff84299b2a1364de28d872c9f9510d3426a83e8a9d2e34" }, "dollar": { "category": "objects", "moji": "💵", + "description": "banknote with dollar sign", "unicodeVersion": "6.0", "digest": "52438e38867aedc021740bb41f9ba336e75a50faa148419412a01d75d8c93155" }, "dolls": { "category": "objects", "moji": "🎎", + "description": "japanese dolls", "unicodeVersion": "6.0", "digest": "a687184e9a0915deef44bb3cacfb19d3f3f19cf2c110f1da90191dd567333c57" }, "dolphin": { "category": "nature", "moji": "🐬", + "description": "dolphin", "unicodeVersion": "6.0", "digest": "0b7ee08f4236232ca533ed3a3023d28020d36f178efaec5ce8b0e13a84778512" }, "door": { "category": "objects", "moji": "🚪", + "description": "door", "unicodeVersion": "6.0", "digest": "984a9ca88852ebdb539e0c385d9c6ffe5010e9189bc372a3d00f5c8d44c8e6f5" }, "doughnut": { "category": "food", "moji": "🍩", + "description": "doughnut", "unicodeVersion": "6.0", "digest": "27634587e6a53807baa32157bb06b0e115c8ad8aefebba7ebb0b65a084170e3a" }, "dove": { "category": "nature", "moji": "🕊", + "description": "dove of peace", "unicodeVersion": "7.0", "digest": "7c665f8594ffa53e72b01647e9d27360fb87d52d02fe9f20fc5fda08f9797dc3" }, "dragon": { "category": "nature", "moji": "🐉", + "description": "dragon", "unicodeVersion": "6.0", "digest": "2abcb3d945d848e34ffc76203b29ef26df7458856166fffd155611f7bbe72652" }, "dragon_face": { "category": "nature", "moji": "🐲", + "description": "dragon face", "unicodeVersion": "6.0", "digest": "0030548931b931e3b51f26cf660394aee36499e688ba83ce9cfccb635dcd4d54" }, "dress": { "category": "people", "moji": "👗", + "description": "dress", "unicodeVersion": "6.0", "digest": "96ceba928fb356f7c0ae99bf22552321f08a65d5f1c0340ab89641219ad366ad" }, "dromedary_camel": { "category": "nature", "moji": "🐪", + "description": "dromedary camel", "unicodeVersion": "6.0", "digest": "e06ef69c29f0fb12481727c0b4124e700572d3d7955e173279320f43f286518d" }, "drooling_face": { "category": "people", "moji": "🤤", + "description": "drooling face", "unicodeVersion": "9.0", "digest": "5203cb05cd266d7a7c929ab40364ad68571d380d9c7ff93a8d6d55261abaa1ba" }, "droplet": { "category": "nature", "moji": "💧", + "description": "droplet", "unicodeVersion": "6.0", "digest": "6475b4a4460a672c436a68f282ac97fb31e2934db4b80620063ee816159aa7c3" }, "drum": { "category": "activity", "moji": "🥁", + "description": "drum with drumsticks", "unicodeVersion": "9.0", "digest": "0d0639980b1a5dcbf1c3e7ef47263fb6543b871242c58452a8c2f642525d9dd8" }, "duck": { "category": "nature", "moji": "🦆", + "description": "duck", "unicodeVersion": "9.0", "digest": "8f8373798a7727368b32328e7a9a349727a949e7391ddd243b6456141a4f7e94" }, "dvd": { "category": "objects", "moji": "📀", + "description": "dvd", "unicodeVersion": "6.0", "digest": "3b7903285d91277181c26fdc9df857761bbac509d352e320c2519ea3b132704f" }, "e-mail": { "category": "objects", "moji": "📧", + "description": "e-mail symbol", "unicodeVersion": "6.0", "digest": "39b5a57a2376e4a1137e381be02a1775bd580e0371438f5297a401ea634f1830" }, "eagle": { "category": "nature", "moji": "🦅", + "description": "eagle", "unicodeVersion": "9.0", "digest": "b44fd4f61b83c5114358a272343ac9b0eabbc70847f739bbdbf8aae3ade5bc1d" }, "ear": { "category": "people", "moji": "👂", + "description": "ear", "unicodeVersion": "6.0", "digest": "4fdeb5a46e69311ecfd09c5b45c9018c24b625e28475cca8fa516b086ef952f8" }, "ear_of_rice": { "category": "nature", "moji": "🌾", + "description": "ear of rice", "unicodeVersion": "6.0", "digest": "2997c340c2b333d6ba9b73f94ff1a1881735fe0cc4f0c72d7719b305499fc425" }, "ear_tone1": { "category": "people", "moji": "👂🏻", + "description": "ear tone 1", "unicodeVersion": "8.0", "digest": "5ca759b8569a377a4e63e30d94b585b9f76d15348a8a0c1ba19fdc522790615e" }, "ear_tone2": { "category": "people", "moji": "👂🏼", + "description": "ear tone 2", "unicodeVersion": "8.0", "digest": "12aafb3ef2cfcdc892b2877c2e24920620f0f77f850e12afbfe55eadce9e37df" }, "ear_tone3": { "category": "people", "moji": "👂🏽", + "description": "ear tone 3", "unicodeVersion": "8.0", "digest": "f4d28d9f72cf116ac92d80061eb84c918d6523bf53b2ad526f5457aba487d527" }, "ear_tone4": { "category": "people", "moji": "👂🏾", + "description": "ear tone 4", "unicodeVersion": "8.0", "digest": "eaa9453670f7e3adc6ec6934ee70efc9bf60fe6c99c5804b7ba9e3804aec65de" }, "ear_tone5": { "category": "people", "moji": "👂🏿", + "description": "ear tone 5", "unicodeVersion": "8.0", "digest": "54bd0782419489556b80e9e0d15b05df74757aa4e04ba565f45c20d3dd60e3f1" }, "earth_africa": { "category": "nature", "moji": "🌍", + "description": "earth globe europe-africa", "unicodeVersion": "6.0", "digest": "c691a6f591f5a07b268fd64efe113e81cec8d5963ad83ced2537422343ff7ecf" }, "earth_americas": { "category": "nature", "moji": "🌎", + "description": "earth globe americas", "unicodeVersion": "6.0", "digest": "a9c60cf8341ff59a9cc1a715b7144af734fcd28915a8e003a31ebf2abf9aedb1" }, "earth_asia": { "category": "nature", "moji": "🌏", + "description": "earth globe asia-australia", "unicodeVersion": "6.0", "digest": "ee2beb61fb8c87279161c5a8c4ad17bb71ce790123f8fa33522941d027e060a5" }, "egg": { "category": "food", "moji": "🥚", + "description": "egg", "unicodeVersion": "9.0", "digest": "72b9c841af784e7cbccbbe48ba833df5cecdd284397c199cab079872e879d92f" }, "eggplant": { "category": "food", "moji": "🍆", + "description": "aubergine", "unicodeVersion": "6.0", "digest": "ec0a460e0cf0e615f51279677594a899672e1b4ecd9396e17a8cfa2a3efe5238" }, "eight": { "category": "symbols", "moji": "8️⃣", + "description": "keycap digit eight", "unicodeVersion": "3.0", "digest": "57ff905033a32747690adba6486d12b09eb4d45de556f4e1ab6fb04e1fb861a8" }, "eight_pointed_black_star": { "category": "symbols", "moji": "✴", + "description": "eight pointed black star", "unicodeVersion": "1.1", "digest": "7bf11f6e28591e3d0625296aaabf4ecb75c982e425abf3049339e93494acc17e" }, "eight_spoked_asterisk": { "category": "symbols", "moji": "✳", + "description": "eight spoked asterisk", "unicodeVersion": "1.1", "digest": "bb0758e7cc0e357285937671a91489bd32ce9d248eecdcc9c275a53a66325b26" }, "eject": { "category": "symbols", "moji": "⏏", + "description": "eject symbol", "unicodeVersion": "4.0", "digest": "eeb0cd23ead0c965e307de517a6805265f0c780c3e454e64bc4c1425dfe7548e" }, "electric_plug": { "category": "objects", "moji": "🔌", + "description": "electric plug", "unicodeVersion": "6.0", "digest": "b10ce87af86fa4f4022572ceb5ecd73bea867347a86832a7ea248364b0aad8d0" }, "elephant": { "category": "nature", "moji": "🐘", + "description": "elephant", "unicodeVersion": "6.0", "digest": "b7750f4b013fbd28ac5330e1694ef4d3b4a9c6fc7b807879db0c24b035a16c29" }, "end": { "category": "symbols", "moji": "🔚", + "description": "end with leftwards arrow above", "unicodeVersion": "6.0", "digest": "dd93aee6986eb637a8b58f234da47568b88525599f73246e322af030351997a2" }, "envelope": { "category": "objects", "moji": "✉", + "description": "envelope", "unicodeVersion": "1.1", "digest": "f5a512022a2f5280f372ff39c22cbda815f698710ca66f8f8c4d08418f98ca78" }, "envelope_with_arrow": { "category": "objects", "moji": "📩", + "description": "envelope with downwards arrow above", "unicodeVersion": "6.0", "digest": "f8643212e6a94f58ccf2bcedc54c5fda8ebeab274f4a8803f253de5f50ddb1d6" }, "euro": { "category": "objects", "moji": "💶", + "description": "banknote with euro sign", "unicodeVersion": "6.0", "digest": "3af3e223e8f26468a94f6f5c17198432656e8d20b3bab31566c2b5a86e717df4" }, "european_castle": { "category": "travel", "moji": "🏰", + "description": "european castle", "unicodeVersion": "6.0", "digest": "21082d0be7e3b2794e59ff0170da0cfe42a9b734cf02704603e3b52ff48202ba" }, "european_post_office": { "category": "travel", "moji": "🏤", + "description": "european post office", "unicodeVersion": "6.0", "digest": "02b4c7602939f0cb9cb2b4e05996bcdb6bd93cf8025c2ea02db8cbe13ca397d0" }, "evergreen_tree": { "category": "nature", "moji": "🌲", + "description": "evergreen tree", "unicodeVersion": "6.0", "digest": "74b226098e66c0a94a92e0f22b9d631736e12dca72c34182c9d0ba56aa593172" }, "exclamation": { "category": "symbols", "moji": "❗", + "description": "heavy exclamation mark symbol", "unicodeVersion": "5.2", "digest": "45b87ae4593656d7da49ff5645fb6a2a18d582553295358da9f09f1ae8272445" }, "expressionless": { "category": "people", "moji": "😑", + "description": "expressionless face", "unicodeVersion": "6.1", "digest": "34e2a1c8121f4f0bc4ce33d226d8cc1a4ebf5260746df2b23e29eef24ee9372e" }, "eye": { "category": "people", "moji": "👁", + "description": "eye", "unicodeVersion": "7.0", "digest": "79ecff79c2edee630e72725b54e67ee2e96d24ca03fef2954a56a09c0a2227f8" }, "eye_in_speech_bubble": { "category": "symbols", "moji": "👁🗨", + "description": "eye in speech bubble", "unicodeVersion": "7.0", "digest": "c0050c026c2a3060723cab2df2603c1c7da7ed81faedb9ebe16cd89721928a55" }, "eyeglasses": { "category": "people", "moji": "👓", + "description": "eyeglasses", "unicodeVersion": "6.0", "digest": "d4a9585d6c43ef514a97c45c64607162e775a45544821f1470c6f8f25b93ab81" }, "eyes": { "category": "people", "moji": "👀", + "description": "eyes", "unicodeVersion": "6.0", "digest": "1d5cae0b9b2e51e1de54295685d7f0c72ee794e2e6335a95b1d056c7e77260e8" }, "face_palm": { "category": "people", "moji": "🤦", + "description": "face palm", "unicodeVersion": "9.0", "digest": "4ec873048b34b1bb34430724cf28e4bee6c0a9eee88ce39b9d1565047dc92420" }, "face_palm_tone1": { "category": "people", "moji": "🤦🏻", + "description": "face palm tone 1", "unicodeVersion": "9.0", "digest": "e93ef92b4c01dbea6c400e708e23dd36da92ccfbf5eb4f177b3b20c3a46bdc19" }, "face_palm_tone2": { "category": "people", "moji": "🤦🏼", + "description": "face palm tone 2", "unicodeVersion": "9.0", "digest": "22c8bf9fd9fa2ed9dca7a6397ed00ba6cfe9aeef2b0fb7b516ee4dda0df050ea" }, "face_palm_tone3": { "category": "people", "moji": "🤦🏽", + "description": "face palm tone 3", "unicodeVersion": "9.0", "digest": "c0b8bb9d2423e6787b6bdf1ca5a13f52853e4f48a9a1af0f2d4af1364fff022e" }, "face_palm_tone4": { "category": "people", "moji": "🤦🏾", + "description": "face palm tone 4", "unicodeVersion": "9.0", "digest": "f522ab186adcbb4549ea2c03500cdd7a86add548e43ebf7a54d58cc24deea072" }, "face_palm_tone5": { "category": "people", "moji": "🤦🏿", + "description": "face palm tone 5", "unicodeVersion": "9.0", "digest": "363507ae7178b5ec583635f47bcab10c897346f48b85d8759b1004c32cd8ad65" }, "factory": { "category": "travel", "moji": "🏭", + "description": "factory", "unicodeVersion": "6.0", "digest": "c7aeb61ed8b0ac5c91d5197c73f1e2bb801921c22a76bb82c7659d990680dcb0" }, "fallen_leaf": { "category": "nature", "moji": "🍂", + "description": "fallen leaf", "unicodeVersion": "6.0", "digest": "81fce04231d48db0e55f3697f930e9a7e3306bed5e35f1234e98c40a24ac5626" }, "family": { "category": "people", "moji": "👪", + "description": "family", "unicodeVersion": "6.0", "digest": "06f2ce63768ffe43b3d9b2a9660b34d043f37b3c91610dd62343ba21df8ecbe5" }, "family_mmb": { "category": "people", "moji": "👨👨👦", + "description": "family (man,man,boy)", "unicodeVersion": "6.0", "digest": "41a18405be796699a7eb7c36ab6f7d898e322749997f45387377acf5bb16a50f" }, "family_mmbb": { "category": "people", "moji": "👨👨👦👦", + "description": "family (man,man,boy,boy)", "unicodeVersion": "6.0", "digest": "87255d1d18c6971c8c083c818e598424c1bd717eed892478b7e9516639dbfb45" }, "family_mmg": { "category": "people", "moji": "👨👨👧", + "description": "family (man,man,girl)", "unicodeVersion": "6.0", "digest": "a132b1b8f10b318d8e23aee15dab4caa14528aeb3c89966d4bcc25fb54af72ad" }, "family_mmgb": { "category": "people", "moji": "👨👨👧👦", + "description": "family (man,man,girl,boy)", "unicodeVersion": "6.0", "digest": "eb2bc1966df406aaf38ce5a58db9324162799cdacf31f74f40e6384807a8efc2" }, "family_mmgg": { "category": "people", "moji": "👨👨👧👧", + "description": "family (man,man,girl,girl)", "unicodeVersion": "6.0", "digest": "24f3d60f98fbd6b687f7cacfb629390b90509a754036e5439ae5294759c0606b" }, "family_mwbb": { "category": "people", "moji": "👨👩👦👦", + "description": "family (man,woman,boy,boy)", "unicodeVersion": "6.0", "digest": "2f77692bcb9275c4df501b64a18401dcaf8c68b21f26fbdad59b1feab0c98fd1" }, "family_mwg": { "category": "people", "moji": "👨👩👧", + "description": "family (man,woman,girl)", "unicodeVersion": "6.0", "digest": "1a976d13127665d9386cebfdb24e5572dc499bda484c0ee05585886edc616130" }, "family_mwgb": { "category": "people", "moji": "👨👩👧👦", + "description": "family (man,woman,girl,boy)", "unicodeVersion": "6.0", "digest": "960ec2cbac13ef208e73644cd36711b83e6c070c36950f834f3669812839b7f8" }, "family_mwgg": { "category": "people", "moji": "👨👩👧👧", + "description": "family (man,woman,girl,girl)", "unicodeVersion": "6.0", "digest": "8353b03dfa5c24aba75a0abdfdac01603f593819d54b4c7f2f88aafb31da0c6a" }, "family_wwb": { "category": "people", "moji": "👩👩👦", + "description": "family (woman,woman,boy)", "unicodeVersion": "6.0", "digest": "07a5dd397718c553573689f6512f386729c13a12d5dc78be47c06405769cd98a" }, "family_wwbb": { "category": "people", "moji": "👩👩👦👦", + "description": "family (woman,woman,boy,boy)", "unicodeVersion": "6.0", "digest": "b627f460f1da0d47b0b662402940b2b77c9538d380d05436dfca4b456c50c939" }, "family_wwg": { "category": "people", "moji": "👩👩👧", + "description": "family (woman,woman,girl)", "unicodeVersion": "6.0", "digest": "2d6f373bed53f1028f0fbe9caf036465a351f37b9e00fca7d722cc5a1984f251" }, "family_wwgb": { "category": "people", "moji": "👩👩👧👦", + "description": "family (woman,woman,girl,boy)", "unicodeVersion": "6.0", "digest": "72be5c85e1621f73d6794edd6e428febdb366b9e4c816f7829897fd1ab34642b" }, "family_wwgg": { "category": "people", "moji": "👩👩👧👧", + "description": "family (woman,woman,girl,girl)", "unicodeVersion": "6.0", "digest": "c39e0916069460d2d9741bddf58e76f5d6a09254cba0eeb262345adf8630bc32" }, "fast_forward": { "category": "symbols", "moji": "⏩", + "description": "black right-pointing double triangle", "unicodeVersion": "6.0", "digest": "e7d2d8085cfd406c2b096e8dd147dd3722290a5727b1f7df185989526a2335ec" }, "fax": { "category": "objects", "moji": "📠", + "description": "fax machine", "unicodeVersion": "6.0", "digest": "ff85ffa440c5379c9b138ebe2d7912d6098da3b37a051b80442d5557b7f993b0" }, "fearful": { "category": "people", "moji": "😨", + "description": "fearful face", "unicodeVersion": "6.0", "digest": "b72bdf7d075d5c4e38bbd8512fb45fda2e85c9c8732a47e67575ae9f2ed4c5df" }, "feet": { "category": "nature", "moji": "🐾", + "description": "paw prints", "unicodeVersion": "6.0", "digest": "45aca538d3a9831a0c7de491e5656c17705c07b8f4ac8e85254656b608976016" }, "fencer": { "category": "activity", "moji": "🤺", + "description": "fencer", "unicodeVersion": "9.0", "digest": "5db00fa456af9f6c7cb88d300579dd63e426bcb97ad25486b664aff25c688e21" }, "ferris_wheel": { "category": "travel", "moji": "🎡", + "description": "ferris wheel", "unicodeVersion": "6.0", "digest": "24b4551b7b79a2a5fd73de61542f2b444f896a52030c5f29791c8fcfcc28b95c" }, "ferry": { "category": "travel", "moji": "⛴", + "description": "ferry", "unicodeVersion": "5.2", "digest": "5002a72af2e3c4cef9a36ad5987aeed7d99f96bfd13e56f78957315ec7e749a3" }, "field_hockey": { "category": "activity", "moji": "🏑", + "description": "field hockey stick and ball", "unicodeVersion": "8.0", "digest": "4ee091d96161ba719ab8fd6f2b03f96d902a6f22cffe0563b930618bb8ac2b67" }, "file_cabinet": { "category": "objects", "moji": "🗄", + "description": "file cabinet", "unicodeVersion": "7.0", "digest": "92914147bf93e6d64271ff99d217a18a9850a367d08a5f9f458ecf9311a5bbe9" }, "file_folder": { "category": "objects", "moji": "📁", + "description": "file folder", "unicodeVersion": "6.0", "digest": "62a42a929267cfbfdb795ead381c9657c343458bc5fca95ea8a0ab892c61d4f6" }, "film_frames": { "category": "objects", "moji": "🎞", + "description": "film frames", "unicodeVersion": "7.0", "digest": "4da212148cadb9c4ea91e60d2d8316e38cea99ef4f14afc023711dd7c54ade5a" }, "fingers_crossed": { "category": "people", "moji": "🤞", + "description": "hand with first and index finger crossed", "unicodeVersion": "9.0", "digest": "a5c797ead191b9712e185083266b455cdf09f6a34c10f8c51aa145e6073427e1" }, "fingers_crossed_tone1": { "category": "people", "moji": "🤞🏻", + "description": "hand with index and middle fingers crossed tone 1", "unicodeVersion": "9.0", "digest": "db56d47bf887f2d8459a3aaba23f15c0087234ae5a54125052e7046e034a4988" }, "fingers_crossed_tone2": { "category": "people", "moji": "🤞🏼", + "description": "hand with index and middle fingers crossed tone 2", "unicodeVersion": "9.0", "digest": "19f1bcca3991db7ed2037278c0baab6cd7f12aeaf2e0074de402c4d9e45c1899" }, "fingers_crossed_tone3": { "category": "people", "moji": "🤞🏽", + "description": "hand with index and middle fingers crossed tone 3", "unicodeVersion": "9.0", "digest": "895a3314f6a310f31f7e728bcca20ff834fbfac62ce00e27e3ea5ad0dfc1ba35" }, "fingers_crossed_tone4": { "category": "people", "moji": "🤞🏾", + "description": "hand with index and middle fingers crossed tone 4", "unicodeVersion": "9.0", "digest": "fcb5c4de2001d23a5df1b8702624d134b7f94e93e2dcc8adf6c1033c77722b0e" }, "fingers_crossed_tone5": { "category": "people", "moji": "🤞🏿", + "description": "hand with index and middle fingers crossed tone 5", "unicodeVersion": "9.0", "digest": "50132c78d530b048c21be4e788b446872a79b3b3a91009db12f4021c44c8469d" }, "fire": { "category": "nature", "moji": "🔥", + "description": "fire", "unicodeVersion": "6.0", "digest": "b3e67c913903d900f5e50e7e7e4d7e9370bb6ceedfbee548be39e4c9e4b69416" }, "fire_engine": { "category": "travel", "moji": "🚒", + "description": "fire engine", "unicodeVersion": "6.0", "digest": "c3a518f27d625e3b62dffa227eb82764bf0a147f10ec0e7f4f43f3f96751af20" }, "fireworks": { "category": "travel", "moji": "🎆", + "description": "fireworks", "unicodeVersion": "6.0", "digest": "b62ae08a00c0cc6eba8f9666c8fd9946ce57c3cfc01fe99542a8690a4a566a65" }, "first_place": { "category": "activity", "moji": "🥇", + "description": "first place medal", "unicodeVersion": "9.0", "digest": "e3de5d9f14f05544dbee5965cc2baa20e7b417a488c8a18598979038860fd901" }, "first_quarter_moon": { "category": "nature", "moji": "🌓", + "description": "first quarter moon symbol", "unicodeVersion": "6.0", "digest": "a207ce93084448622a4a5c49c85c566a9fda6be7337c86a013eeb713fe47fd29" }, "first_quarter_moon_with_face": { "category": "nature", "moji": "🌛", + "description": "first quarter moon with face", "unicodeVersion": "6.0", "digest": "1d1f54a5075f2311bcc017c44898b9d8c58edc13b298d58c238fff9ab8ee2ef3" }, "fish": { "category": "nature", "moji": "🐟", + "description": "fish", "unicodeVersion": "6.0", "digest": "8f62f08fbeaf39694c19816b5c7d4f292017fe5bf9f8dd7e40f1630f5f83b28b" }, "fish_cake": { "category": "food", "moji": "🍥", + "description": "fish cake with swirl design", "unicodeVersion": "6.0", "digest": "5a6ca2100c8830927b22afa6f1d2fc821f5692cd23507fe5a776f6e085cbbfb2" }, "fishing_pole_and_fish": { "category": "activity", "moji": "🎣", + "description": "fishing pole and fish", "unicodeVersion": "6.0", "digest": "f8fb84eccceec88321b0a2a46f732ecfc378f787c19c27ac1327735f1ca9a48b" }, "fist": { "category": "people", "moji": "✊", + "description": "raised fist", "unicodeVersion": "6.0", "digest": "557f96d85615b8d78436bc67266115bfc8556c97c14f7909dfda1cf134e8344f" }, "fist_tone1": { "category": "people", "moji": "✊🏻", + "description": "raised fist tone 1", "unicodeVersion": "8.0", "digest": "6c1b946f9e01abc39b5085e24e8b6077fc0e34188e8daa30c6a3adddd387413e" }, "fist_tone2": { "category": "people", "moji": "✊🏼", + "description": "raised fist tone 2", "unicodeVersion": "8.0", "digest": "e9b9e1ec638dca4d5e1519bca7338f58cce2f2a282ee4c3581e8643166fc415f" }, "fist_tone3": { "category": "people", "moji": "✊🏽", + "description": "raised fist tone 3", "unicodeVersion": "8.0", "digest": "8c14d24055c143960b3d2a27fe23c55d2d3ac5f84f87e4e876616235e8698c7f" }, "fist_tone4": { "category": "people", "moji": "✊🏾", + "description": "raised fist tone 4", "unicodeVersion": "8.0", "digest": "923f034f481e952e6e5d1664588f99f79bd5416d4197b0ade6621f2669ce5765" }, "fist_tone5": { "category": "people", "moji": "✊🏿", + "description": "raised fist tone 5", "unicodeVersion": "8.0", "digest": "d691d2902216080916a29047e07d7a5bf2aed07e062067ca9d01cbf6fdf48c8d" }, "five": { "category": "symbols", "moji": "5️⃣", + "description": "keycap digit five", "unicodeVersion": "3.0", "digest": "8f03f62fdbf744ae49c8a60fbf715ebfccbd6b62d91148e0923907006f3c2726" }, "flag_ac": { "category": "flags", "moji": "🇦🇨", + "description": "ascension", "unicodeVersion": "6.0", "digest": "2e5c08535dc8ea96422d56a36b4fffc0b3bd2a13f2ab0d8dbd0e3a29bf3fc40c" }, "flag_ad": { "category": "flags", "moji": "🇦🇩", + "description": "andorra", "unicodeVersion": "6.0", "digest": "184fdcf790b8e2fd851b2b2b32f8636c595dd289734d12dc01ae4aa177e2043a" }, "flag_ae": { "category": "flags", "moji": "🇦🇪", + "description": "the united arab emirates", "unicodeVersion": "6.0", "digest": "4a3257a9ce118e97567e76280f24d60fb555f1bada2eb26a2442a47f9398d21e" }, "flag_af": { "category": "flags", "moji": "🇦🇫", + "description": "afghanistan", "unicodeVersion": "6.0", "digest": "0f6c719cac7ab3140694f6b580787ecdbf503e38f16de7ec5803f7d06a088ec3" }, "flag_ag": { "category": "flags", "moji": "🇦🇬", + "description": "antigua and barbuda", "unicodeVersion": "6.0", "digest": "92bf5a0e74564739862e9ba79331ffa656b7bae2ace0fc8dfd288984e4d510d4" }, "flag_ai": { "category": "flags", "moji": "🇦🇮", + "description": "anguilla", "unicodeVersion": "6.0", "digest": "aeaadc7ffafd8a1e01fdabc69d35f725d5f737b4c284a36191d96729f4e66e8f" }, "flag_al": { "category": "flags", "moji": "🇦🇱", + "description": "albania", "unicodeVersion": "6.0", "digest": "5ce7866d214d18c5f3438d480d14e77d104c4de679f0fdfca8cf0a44ce48eeea" }, "flag_am": { "category": "flags", "moji": "🇦🇲", + "description": "armenia", "unicodeVersion": "6.0", "digest": "b40f5705f0cf9ef0fa7ffff0b371c4099319001ce79f894c317912f4dc5de4c8" }, "flag_ao": { "category": "flags", "moji": "🇦🇴", + "description": "angola", "unicodeVersion": "6.0", "digest": "eab6fbc1824d6e3cd152e8ec1d82e1beaebe02b53b35c6f7a883b8548af02f3a" }, "flag_aq": { "category": "flags", "moji": "🇦🇶", + "description": "antarctica", "unicodeVersion": "6.0", "digest": "367f6677a683a5f0e7248ab3a8f46d06ba146a0fd75004c70bac0e913147cdaa" }, "flag_ar": { "category": "flags", "moji": "🇦🇷", + "description": "argentina", "unicodeVersion": "6.0", "digest": "f0dc466b3216957f2679d7208c2d7cf288448b0739b9270a7c5fa717577bdf25" }, "flag_as": { "category": "flags", "moji": "🇦🇸", + "description": "american samoa", "unicodeVersion": "6.0", "digest": "fcb7a865c7763c63b23485cc27207b99a3a8492e83d5b5ee2df259a9f68f77d6" }, "flag_at": { "category": "flags", "moji": "🇦🇹", + "description": "austria", "unicodeVersion": "6.0", "digest": "1d3d58e9abc034f9a093a94716eddf9811d54dfaf27969fd322b3809fac70217" }, "flag_au": { "category": "flags", "moji": "🇦🇺", + "description": "australia", "unicodeVersion": "6.0", "digest": "789563b64c71a5ad49078d335dc166ef614edb56d1e401885d32fb191c198fbd" }, "flag_aw": { "category": "flags", "moji": "🇦🇼", + "description": "aruba", "unicodeVersion": "6.0", "digest": "1504dc3fd8457b44fdf75c15e136dc46a13e8342d1f98949728cdc1238843e0c" }, "flag_ax": { "category": "flags", "moji": "🇦🇽", + "description": "åland islands", "unicodeVersion": "6.0", "digest": "e96fa3525f3be25016a4cf8428261735f3ed5fc9fe5b827b461746a3f08877bf" }, "flag_az": { "category": "flags", "moji": "🇦🇿", + "description": "azerbaijan", "unicodeVersion": "6.0", "digest": "12c366ac2c38b91314fb29056e09fa6e7417766cebde3045859cdb127549f4a2" }, "flag_ba": { "category": "flags", "moji": "🇧🇦", + "description": "bosnia and herzegovina", "unicodeVersion": "6.0", "digest": "0819ea3901510ac20c7f10e67e5f6c818210f17a362c1d12e299c41feb07f828" }, "flag_bb": { "category": "flags", "moji": "🇧🇧", + "description": "barbados", "unicodeVersion": "6.0", "digest": "cf32778a272ed6cbc8e783b59befd9b204009c69c61a425e148d867808b7fab9" }, "flag_bd": { "category": "flags", "moji": "🇧🇩", + "description": "bangladesh", "unicodeVersion": "6.0", "digest": "e6ed186644a874588e879513aec92f8107220dcdd14c766dee61f266ce045665" }, "flag_be": { "category": "flags", "moji": "🇧🇪", + "description": "belgium", "unicodeVersion": "6.0", "digest": "4d941011d15d9f6e755d6f7694884758baf17ac0691bf5d63700f8d6dbcdb948" }, "flag_bf": { "category": "flags", "moji": "🇧🇫", + "description": "burkina faso", "unicodeVersion": "6.0", "digest": "fcc57dbda9a86f725f558b6c6309484c97e65f1644aae4f9fb5e642681f6c2e0" }, "flag_bg": { "category": "flags", "moji": "🇧🇬", + "description": "bulgaria", "unicodeVersion": "6.0", "digest": "816c47ed96c36c90723da150645902ea8ba18b44757fdd776c7b3542cfecfb18" }, "flag_bh": { "category": "flags", "moji": "🇧🇭", + "description": "bahrain", "unicodeVersion": "6.0", "digest": "2cd5c21775a6e73f59d08c9ee0cedf4e8241e562eab939573501d47681987737" }, "flag_bi": { "category": "flags", "moji": "🇧🇮", + "description": "burundi", "unicodeVersion": "6.0", "digest": "2da82acbec5518360633c1b0b56d55a79b67237f67d92af5e5cd75a2f3bd550e" }, "flag_bj": { "category": "flags", "moji": "🇧🇯", + "description": "benin", "unicodeVersion": "6.0", "digest": "8fe8c34651eb4e28ab395261a5b72b6f37579535ed676d15de131914e19c0436" }, "flag_bl": { "category": "flags", "moji": "🇧🇱", + "description": "saint barthélemy", "unicodeVersion": "6.0", "digest": "d37f2a215ee7ef5b5ab62d2a0c87e90553b17c6ee310f803a71e9fd72db880e7" }, "flag_black": { "category": "objects", "moji": "🏴", + "description": "waving black flag", "unicodeVersion": "6.0", "digest": "3740bfc9bcb3b46b697b8b7c47ab2c3e95eca9dbcba12f2bf98a01302704f203" }, "flag_bm": { "category": "flags", "moji": "🇧🇲", + "description": "bermuda", "unicodeVersion": "6.0", "digest": "ccd21655573f3c955d616c5c7b1eac2be1d4772ff611648d6713ba55d9e4aa9b" }, "flag_bn": { "category": "flags", "moji": "🇧🇳", + "description": "brunei", "unicodeVersion": "6.0", "digest": "54330c3d7a37392e69098c213fd8c78f3faab4e7e5909c039188110422514228" }, "flag_bo": { "category": "flags", "moji": "🇧🇴", + "description": "bolivia", "unicodeVersion": "6.0", "digest": "32aff973b26f4f91ca19dddd7861b564da43cfbee87603d8c004f1111342366c" }, "flag_bq": { "category": "flags", "moji": "🇧🇶", + "description": "caribbean netherlands", "unicodeVersion": "6.0", "digest": "b1ebc959c43f706ca430d8633d9efaa9c60133871506b5f030b730cfb4c19e6f" }, "flag_br": { "category": "flags", "moji": "🇧🇷", + "description": "brazil", "unicodeVersion": "6.0", "digest": "64fb154d71fa34ff4838bc405f3e58a4102cf0cb49ca4b06fc3c7a6bf39671f0" }, "flag_bs": { "category": "flags", "moji": "🇧🇸", + "description": "the bahamas", "unicodeVersion": "6.0", "digest": "c4b07e5f652ab06ece95d3774ce8b1399a935f8a28d440cb13cc8bd0b9728ed5" }, "flag_bt": { "category": "flags", "moji": "🇧🇹", + "description": "bhutan", "unicodeVersion": "6.0", "digest": "901ddbd999dd89a87c1e1208b1470cb4e604a9bc023d0cbcdee64e1bc54079ba" }, "flag_bv": { "category": "flags", "moji": "🇧🇻", + "description": "bouvet island", "unicodeVersion": "6.0", "digest": "bbf6daa6174c6fbbbf541c8274f31b6757c3a16007c2687015ea041fd1e2c6b6" }, "flag_bw": { "category": "flags", "moji": "🇧🇼", + "description": "botswana", "unicodeVersion": "6.0", "digest": "05aa351bc04dc0fe2669441ab500e000d48b1f0d7ad9e885c7abfb898aa0eb3f" }, "flag_by": { "category": "flags", "moji": "🇧🇾", + "description": "belarus", "unicodeVersion": "6.0", "digest": "6eda3b87336ecf0aae4963986d86b916a055d8268c70520303288f235a93b0d9" }, "flag_bz": { "category": "flags", "moji": "🇧🇿", + "description": "belize", "unicodeVersion": "6.0", "digest": "d76ed945b1408558a30a99b8eed6712de968fc49fba1721b5660b8f48087e45a" }, "flag_ca": { "category": "flags", "moji": "🇨🇦", + "description": "canada", "unicodeVersion": "6.0", "digest": "2fd036047d89751c05de5577909b58347883bc89c3b7d90bec28ad4770a98ecd" }, "flag_cc": { "category": "flags", "moji": "🇨🇨", + "description": "cocos (keeling) islands", "unicodeVersion": "6.0", "digest": "837ba181a01c71f05d438d205efaaee99f93b2370c97b13e6132f99860323e36" }, "flag_cd": { "category": "flags", "moji": "🇨🇩", + "description": "the democratic republic of the congo", "unicodeVersion": "6.0", "digest": "318689274b4b3b58aed7fc1654127499a9da69bff1b83e592e86e69d167ce16f" }, "flag_cf": { "category": "flags", "moji": "🇨🇫", + "description": "central african republic", "unicodeVersion": "6.0", "digest": "06d6042849d3b7b217c2b18ba787aae449e8c7d2537e2e5974744ec196062228" }, "flag_cg": { "category": "flags", "moji": "🇨🇬", + "description": "the republic of the congo", "unicodeVersion": "6.0", "digest": "09f45d2dcb5a24d8349ef86e7405cc29ef3d65a908c0bff3221c3b4546547813" }, "flag_ch": { "category": "flags", "moji": "🇨🇭", + "description": "switzerland", "unicodeVersion": "6.0", "digest": "53d6d35aeeebb0b4b1ad858dc3691e649ac73d30b3be76f96d5fe9605fa99386" }, "flag_ci": { "category": "flags", "moji": "🇨🇮", + "description": "cote d'ivoire", "unicodeVersion": "6.0", "digest": "7d85a0c314b7397c9397a54ce2f3a4dc5f40d0234e586dbd8a541a8666f0f51e" }, "flag_ck": { "category": "flags", "moji": "🇨🇰", + "description": "cook islands", "unicodeVersion": "6.0", "digest": "c1aa105fe106ed09ed59a596859a0ce4e65a415c59f63df51961491cb947b136" }, "flag_cl": { "category": "flags", "moji": "🇨🇱", + "description": "chile", "unicodeVersion": "6.0", "digest": "0fffdad0d892f5c08aaa332af1ed2c228583d89a43190e979a3c3cb020d5a723" }, "flag_cm": { "category": "flags", "moji": "🇨🇲", + "description": "cameroon", "unicodeVersion": "6.0", "digest": "e9f55e41a1fd2735a82ad7a7ac39326a944cb20423ffba3608ac53a46036caad" }, "flag_cn": { "category": "flags", "moji": "🇨🇳", + "description": "china", "unicodeVersion": "6.0", "digest": "e2c8fee7e3bd51b13d6083d5bf344abe6b9b642e3cbb099d38b4ce341c99d890" }, "flag_co": { "category": "flags", "moji": "🇨🇴", + "description": "colombia", "unicodeVersion": "6.0", "digest": "51c60d0979bf8342eaff7cda9faf4b0dfab38efaf5ddf3717eb8f0e2a595b15f" }, "flag_cp": { "category": "flags", "moji": "🇨🇵", + "description": "clipperton island", "unicodeVersion": "6.0", "digest": "a0124683aa88cd7da886da70c65796c5ad84eb3751e356e9b2aa8ac249cf0bf9" }, "flag_cr": { "category": "flags", "moji": "🇨🇷", + "description": "costa rica", "unicodeVersion": "6.0", "digest": "907905971b219e617a34eef4839b0bd08d98f3480e2631bce523120dcef95196" }, "flag_cu": { "category": "flags", "moji": "🇨🇺", + "description": "cuba", "unicodeVersion": "6.0", "digest": "d88cea729dc9dbbbcadac0409ec561995f061b2280577c01c6c6b37de347f150" }, "flag_cv": { "category": "flags", "moji": "🇨🇻", + "description": "cape verde", "unicodeVersion": "6.0", "digest": "5ce97944adfce09e96387e6f872256482ac99ccbc60017c4d58ddd15b6fb67a7" }, "flag_cw": { "category": "flags", "moji": "🇨🇼", + "description": "curaçao", "unicodeVersion": "6.0", "digest": "a6fc31bd66ddc2ee8e7bde3aeabfe1c4ad00c9688abae234a541cc1236d68c1b" }, "flag_cx": { "category": "flags", "moji": "🇨🇽", + "description": "christmas island", "unicodeVersion": "6.0", "digest": "1261b32bfa22fa1441f5390ff499ac6b921d7ac59cc8acda3deb3a2beb4fb345" }, "flag_cy": { "category": "flags", "moji": "🇨🇾", + "description": "cyprus", "unicodeVersion": "6.0", "digest": "82b1baa05ecffa0ea1f9a83b518163cbd7910985a21955740520bb16b7bb624f" }, "flag_cz": { "category": "flags", "moji": "🇨🇿", + "description": "the czech republic", "unicodeVersion": "6.0", "digest": "a169b18968992a52299b67c24fba495e84de28dec2ebb947a08e0d615ac54a5a" }, "flag_de": { "category": "flags", "moji": "🇩🇪", + "description": "germany", "unicodeVersion": "6.0", "digest": "99d1906944966a188c72ae592362ed907e2a0bfe95263955c34a0941507b30c1" }, "flag_dg": { "category": "flags", "moji": "🇩🇬", + "description": "diego garcia", "unicodeVersion": "6.0", "digest": "dd45e1afe792fca57d4161434bf611bcb7170072d63e4a27fb9dcd6e8912621e" }, "flag_dj": { "category": "flags", "moji": "🇩🇯", + "description": "djibouti", "unicodeVersion": "6.0", "digest": "e90ba4e98fca71ff0ca5e65c28b911cc52f043428f375d8f954ecbd3b0c8f4dd" }, "flag_dk": { "category": "flags", "moji": "🇩🇰", + "description": "denmark", "unicodeVersion": "6.0", "digest": "65b3b5f31935a4969d81fedbb8279c7ad32da454d15c5eafcceba5d140927c77" }, "flag_dm": { "category": "flags", "moji": "🇩🇲", + "description": "dominica", "unicodeVersion": "6.0", "digest": "f6225ded6d2cfd6c182ab1a53b8c49dc9df195df11eb7ff27b15f5d3721ba0eb" }, "flag_do": { "category": "flags", "moji": "🇩🇴", + "description": "the dominican republic", "unicodeVersion": "6.0", "digest": "dc2ad6856cebbe47c5bd7f5dcf087e4f680d396b2d49440a9b71f0ad49fb8102" }, "flag_dz": { "category": "flags", "moji": "🇩🇿", + "description": "algeria", "unicodeVersion": "6.0", "digest": "ea69fffc4d545f9c0fcef6768257501952955ba4d274c9b81843229a1265c5ed" }, "flag_ea": { "category": "flags", "moji": "🇪🇦", + "description": "ceuta, melilla", "unicodeVersion": "6.0", "digest": "e63bfe15428c481dd23b569e7aaf0a76106e58a946995b4415a81097ecd53b7d" }, "flag_ec": { "category": "flags", "moji": "🇪🇨", + "description": "ecuador", "unicodeVersion": "6.0", "digest": "0cdabf85cd567047fda1d9a4508220cab829943a7c542c315078db0aac33edac" }, "flag_ee": { "category": "flags", "moji": "🇪🇪", + "description": "estonia", "unicodeVersion": "6.0", "digest": "6dc4e3377e8e2af3ff40cf940a914bc7840980b4a14e7da86954343f2b1025fe" }, "flag_eg": { "category": "flags", "moji": "🇪🇬", + "description": "egypt", "unicodeVersion": "6.0", "digest": "2ed6bc056015694d75993eb5ee3c1850921d5630681207b04dfbdb982ab346a2" }, "flag_eh": { "category": "flags", "moji": "🇪🇭", + "description": "western sahara", "unicodeVersion": "6.0", "digest": "72adb55943e4df99c00843c65463718609d937480f73dcf4a4451d46b9967a5e" }, "flag_er": { "category": "flags", "moji": "🇪🇷", + "description": "eritrea", "unicodeVersion": "6.0", "digest": "3fa59331eb5300c8c1f7b1f1bc15cfcfe688da6fa4a79341854598086a44eebc" }, "flag_es": { "category": "flags", "moji": "🇪🇸", + "description": "spain", "unicodeVersion": "6.0", "digest": "1fa1d5cb0a7e8b14aaec758b2e7bf49cdf8f3d09bbcc7dfd589053a432eeae25" }, "flag_et": { "category": "flags", "moji": "🇪🇹", + "description": "ethiopia", "unicodeVersion": "6.0", "digest": "72771decfb214394e4beb594e848ea590c3615800adbba24b5df4c5db6ee9617" }, "flag_eu": { "category": "flags", "moji": "🇪🇺", + "description": "european union", "unicodeVersion": "6.0", "digest": "4bfa1b2ef23764ead5ef7899806f93e13fd29a09c75e61431579a4116c836aa4" }, "flag_fi": { "category": "flags", "moji": "🇫🇮", + "description": "finland", "unicodeVersion": "6.0", "digest": "d0208cdd5b153a2865f9f674179c62871d4675abb0fb639fba88fcd62553f54e" }, "flag_fj": { "category": "flags", "moji": "🇫🇯", + "description": "fiji", "unicodeVersion": "6.0", "digest": "6c5ec41114af3846b093a418f6e2b5ff7a83cb72cecde75a7dc62e8cb6dcfe45" }, "flag_fk": { "category": "flags", "moji": "🇫🇰", + "description": "falkland islands", "unicodeVersion": "6.0", "digest": "c69ad641d53785deff5c3934b7dcfcd3dc32ffc31b6d3e799d0555b03c23fc15" }, "flag_fm": { "category": "flags", "moji": "🇫🇲", + "description": "micronesia", "unicodeVersion": "6.0", "digest": "1e29fb06b273f253c23a9e4aa8ff84bfe22cffb5fa158a0c6f4cdeabe0216990" }, "flag_fo": { "category": "flags", "moji": "🇫🇴", + "description": "faroe islands", "unicodeVersion": "6.0", "digest": "f4907d2f606f4f9d3bef06c6d38e8e88f2a148197b1573668866431a007afc2e" }, "flag_fr": { "category": "flags", "moji": "🇫🇷", + "description": "france", "unicodeVersion": "6.0", "digest": "5a1308ab3cbf6bffcab12588cf3325151a6c72990db7408c2b8605d89f94ed6e" }, "flag_ga": { "category": "flags", "moji": "🇬🇦", + "description": "gabon", "unicodeVersion": "6.0", "digest": "ddc32dee2976507be878ec3d3d2408632ca21bc434cd9f58db4f6ac9774a2db5" }, "flag_gb": { "category": "flags", "moji": "🇬🇧", + "description": "great britain", "unicodeVersion": "6.0", "digest": "6b3bb254d134870b02cb066b06e206f652638a915c84b8649ceb30ec67fbebde" }, "flag_gd": { "category": "flags", "moji": "🇬🇩", + "description": "grenada", "unicodeVersion": "6.0", "digest": "b6a210541ca22d816405f2a7d0d5241dc4d5488c8a36e15bd1e3063f9c41327f" }, "flag_ge": { "category": "flags", "moji": "🇬🇪", + "description": "georgia", "unicodeVersion": "6.0", "digest": "e9a5035b7a46b925737e7f7b0ae2419cc4af0e980fbee5bd916edeef13823367" }, "flag_gf": { "category": "flags", "moji": "🇬🇫", + "description": "french guiana", "unicodeVersion": "6.0", "digest": "ce1bcd8c303897c1c22c5994182f21240b4aa635f0d7ce9944f76cbdbf0e4956" }, "flag_gg": { "category": "flags", "moji": "🇬🇬", + "description": "guernsey", "unicodeVersion": "6.0", "digest": "a435aab3609533ab2d68acd97deba844bfb0fc27b2adac68668223011f23ae5d" }, "flag_gh": { "category": "flags", "moji": "🇬🇭", + "description": "ghana", "unicodeVersion": "6.0", "digest": "7cad43b40f69b9b00cc1b38036789ce774fd3d597c89f0bf38433847ea69be26" }, "flag_gi": { "category": "flags", "moji": "🇬🇮", + "description": "gibraltar", "unicodeVersion": "6.0", "digest": "70e9b17d18bf3e0e4d03f4f824323a57909416e4082ca9d8a0796a6959de4f07" }, "flag_gl": { "category": "flags", "moji": "🇬🇱", + "description": "greenland", "unicodeVersion": "6.0", "digest": "1963d8cca1c1f06b7536b7fb8f5a4782ac0bb05afdf6e481101bce45c58cdd4b" }, "flag_gm": { "category": "flags", "moji": "🇬🇲", + "description": "the gambia", "unicodeVersion": "6.0", "digest": "6c776a8daa3f4daa2597b0025aec06fc0a53aed262e845d4da3897cd7a89c6a1" }, "flag_gn": { "category": "flags", "moji": "🇬🇳", + "description": "guinea", "unicodeVersion": "6.0", "digest": "134cf7c839370d171ae80a72e5d18d32ea1967df19c191d1a4ea446d649e9558" }, "flag_gp": { "category": "flags", "moji": "🇬🇵", + "description": "guadeloupe", "unicodeVersion": "6.0", "digest": "be3e906b039ba4884053c78f4f14de9aa87c5573860ccb69ec766068ae3887c2" }, "flag_gq": { "category": "flags", "moji": "🇬🇶", + "description": "equatorial guinea", "unicodeVersion": "6.0", "digest": "d476059c4ab41f5a1ef88583087362a5bc57cede930126f37041d1546564ab70" }, "flag_gr": { "category": "flags", "moji": "🇬🇷", + "description": "greece", "unicodeVersion": "6.0", "digest": "b9fa9304647aaa08167a07858bb18d778dcc399375f86f580b8d4244794678bc" }, "flag_gs": { "category": "flags", "moji": "🇬🇸", + "description": "south georgia", "unicodeVersion": "6.0", "digest": "de33fbef6e294eb7af36e5b94d8ff573b354a4ff1ebdccf50ca528b86ed601d9" }, "flag_gt": { "category": "flags", "moji": "🇬🇹", + "description": "guatemala", "unicodeVersion": "6.0", "digest": "4160843e5d642df597c8423eb8e3b74deafe304f3d141c8a4d2fc07509e44832" }, "flag_gu": { "category": "flags", "moji": "🇬🇺", + "description": "guam", "unicodeVersion": "6.0", "digest": "3b0cb257ba5b1c3e15d9102410c5f7418da03372e91ce90513de25b9f45283e3" }, "flag_gw": { "category": "flags", "moji": "🇬🇼", + "description": "guinea-bissau", "unicodeVersion": "6.0", "digest": "bdf07a8f93c0f0a573af5f5361be404a3ba65b729c1a4c05b7632c03d85efc72" }, "flag_gy": { "category": "flags", "moji": "🇬🇾", + "description": "guyana", "unicodeVersion": "6.0", "digest": "b47d8c98b747556f827ad0d1169264eb68ecaf9d2fb76595e8c31866361cbfc6" }, "flag_hk": { "category": "flags", "moji": "🇭🇰", + "description": "hong kong", "unicodeVersion": "6.0", "digest": "8e5a54b2e4bd4f5182085299b9648062463da05d535cf0e46a7d9c58eaeb171f" }, "flag_hm": { "category": "flags", "moji": "🇭🇲", + "description": "heard island and mcdonald islands", "unicodeVersion": "6.0", "digest": "63c3e080c5e82a72c6d4cf5997ac823dc02184719ec59aadea6dd41b127abf22" }, "flag_hn": { "category": "flags", "moji": "🇭🇳", + "description": "honduras", "unicodeVersion": "6.0", "digest": "87c1d160db810b5ed208fb33add54f96c17b0f08d87b81f6f09429abf6ec93ac" }, "flag_hr": { "category": "flags", "moji": "🇭🇷", + "description": "croatia", "unicodeVersion": "6.0", "digest": "8b68112f79baea38565673acf4f1cb90675a5829ff17e4cf9415c928b62aed88" }, "flag_ht": { "category": "flags", "moji": "🇭🇹", + "description": "haiti", "unicodeVersion": "6.0", "digest": "05dbd548c310ef1ebd1724aa85d821f8320106b16ddbf1f6442ea37e4407d5e1" }, "flag_hu": { "category": "flags", "moji": "🇭🇺", + "description": "hungary", "unicodeVersion": "6.0", "digest": "5079f3d6f1459e6df8dda5c19d2367ead8f5a755b8874ac999bae58e3c9f47a7" }, "flag_ic": { "category": "flags", "moji": "🇮🇨", + "description": "canary islands", "unicodeVersion": "6.0", "digest": "8dcb18c4b75a60867a68d2f6edbf81e782aafb4b9a0404c8081f872dfe71e432" }, "flag_id": { "category": "flags", "moji": "🇮🇩", + "description": "indonesia", "unicodeVersion": "6.0", "digest": "1b0eb69a158ed3afe24be448d44751f95dcc5cbc7d1393a5753293f16ef0a66c" }, "flag_ie": { "category": "flags", "moji": "🇮🇪", + "description": "ireland", "unicodeVersion": "6.0", "digest": "5fc8c101ad7296224455f72f73c335aa4f676023b68645bafaf69087f69af390" }, "flag_il": { "category": "flags", "moji": "🇮🇱", + "description": "israel", "unicodeVersion": "6.0", "digest": "5aea4207415b7615dcdd69413705aefda700aefd0d27010cd0a0a338d879d9b8" }, "flag_im": { "category": "flags", "moji": "🇮🇲", + "description": "isle of man", "unicodeVersion": "6.0", "digest": "1ee9b3a5f1a52fc6d8369bfd81995fc0567e7a61deacd013701b3ec5fd64502e" }, "flag_in": { "category": "flags", "moji": "🇮🇳", + "description": "india", "unicodeVersion": "6.0", "digest": "202ede502f34d55d180726ac2f29141c6875516f1b3e7ee99f266b16c2fe4bfd" }, "flag_io": { "category": "flags", "moji": "🇮🇴", + "description": "british indian ocean territory", "unicodeVersion": "6.0", "digest": "dd45e1afe792fca57d4161434bf611bcb7170072d63e4a27fb9dcd6e8912621e" }, "flag_iq": { "category": "flags", "moji": "🇮🇶", + "description": "iraq", "unicodeVersion": "6.0", "digest": "bef294772b5ffccd6c061c19d60af66f61b248d78705faf347ade9ebfca2b46d" }, "flag_ir": { "category": "flags", "moji": "🇮🇷", + "description": "iran", "unicodeVersion": "6.0", "digest": "d4faca93577a5546330ab6a09252307e19fb420d89912c0b48ceb90bf409d48e" }, "flag_is": { "category": "flags", "moji": "🇮🇸", + "description": "iceland", "unicodeVersion": "6.0", "digest": "b2fc04226b274009b4d99d92bcb72b255b534b6fd4b76d82dce1575ad975a456" }, "flag_it": { "category": "flags", "moji": "🇮🇹", + "description": "italy", "unicodeVersion": "6.0", "digest": "735760f193855d55460a0fb93dad55ff67253cab63176eceb90b9bde1faead1e" }, "flag_je": { "category": "flags", "moji": "🇯🇪", + "description": "jersey", "unicodeVersion": "6.0", "digest": "671a487a60571d928d2abaf306d0a9ba50239ec54ada14ea29a9a99df658d3cc" }, "flag_jm": { "category": "flags", "moji": "🇯🇲", + "description": "jamaica", "unicodeVersion": "6.0", "digest": "fb9047199d030b78fc0dcfc58d9b524fdb929238d922809da88147b7cebf4211" }, "flag_jo": { "category": "flags", "moji": "🇯🇴", + "description": "jordan", "unicodeVersion": "6.0", "digest": "19f7d536d0293ebf3db49e05a158097cbde467115ef96523a0553808fd0b4178" }, "flag_jp": { "category": "flags", "moji": "🇯🇵", + "description": "japan", "unicodeVersion": "6.0", "digest": "51e971f777fe481ca9f7e077ecb2ce252c3cc0086b76384e7b965cdc337f3f9e" }, "flag_ke": { "category": "flags", "moji": "🇰🇪", + "description": "kenya", "unicodeVersion": "6.0", "digest": "0cec8f068548cfd3e7a20c10af84f97ca415fd6f8ab8b50783bf982e77d7260e" }, "flag_kg": { "category": "flags", "moji": "🇰🇬", + "description": "kyrgyzstan", "unicodeVersion": "6.0", "digest": "5803ea6ab028261923fd7570c670a50518c6f462a2fb4d463531b12c3e382e6f" }, "flag_kh": { "category": "flags", "moji": "🇰🇭", + "description": "cambodia", "unicodeVersion": "6.0", "digest": "287d357afe47179853fd485fb102834ead145598ed892664fc62d245cac16080" }, "flag_ki": { "category": "flags", "moji": "🇰🇮", + "description": "kiribati", "unicodeVersion": "6.0", "digest": "ae4aee0d9cd7a21d4e250d45a484f5f641acdab3d79b437337b25fe34a0b49b0" }, "flag_km": { "category": "flags", "moji": "🇰🇲", + "description": "the comoros", "unicodeVersion": "6.0", "digest": "2d1730acbf5421fd02bd5483e26a86d82ec2fa99f0ff75bfd728a9df7914ad3b" }, "flag_kn": { "category": "flags", "moji": "🇰🇳", + "description": "saint kitts and nevis", "unicodeVersion": "6.0", "digest": "b9ed979db9c6d243b00f61f19a9ec0f2c2390b2e5cace5ad61d9371dc8c670ac" }, "flag_kp": { "category": "flags", "moji": "🇰🇵", + "description": "north korea", "unicodeVersion": "6.0", "digest": "1bab0b9cab8028a95ce7231ad8d88ebcd31601cfa321284bba017ead47f6c729" }, "flag_kr": { "category": "flags", "moji": "🇰🇷", + "description": "korea", "unicodeVersion": "6.0", "digest": "33be8c09ebe273e203aa703cc827d52a6d9bf1699f5445bba13a77af2df45fa6" }, "flag_kw": { "category": "flags", "moji": "🇰🇼", + "description": "kuwait", "unicodeVersion": "6.0", "digest": "04d901a92ea55b13dc4983a9e3adb52dc89c9f3decee86fd06022aa902678b6d" }, "flag_ky": { "category": "flags", "moji": "🇰🇾", + "description": "cayman islands", "unicodeVersion": "6.0", "digest": "10f4d02f33cadd34da89de71a3b763809bad480cd9ae9d2ec000db026bd94cd1" }, "flag_kz": { "category": "flags", "moji": "🇰🇿", + "description": "kazakhstan", "unicodeVersion": "6.0", "digest": "dfaff69a78cf635f7fad41bd5bdcc8003298454708a6178ba7348b1b40c360c1" }, "flag_la": { "category": "flags", "moji": "🇱🇦", + "description": "laos", "unicodeVersion": "6.0", "digest": "4fcfbdc694cf99ae3f832500cdcdedb88c444b6df88bc9b7141f4f26ba3d5bfd" }, "flag_lb": { "category": "flags", "moji": "🇱🇧", + "description": "lebanon", "unicodeVersion": "6.0", "digest": "af4b1f784bea0ec7a712495491dffbd1152cc857a99fd433f76bfeb313819a62" }, "flag_lc": { "category": "flags", "moji": "🇱🇨", + "description": "saint lucia", "unicodeVersion": "6.0", "digest": "40784aa558b75d07ae499c004e2cc5d0b2efdfc3e5be705b5a9f6b70d681c396" }, "flag_li": { "category": "flags", "moji": "🇱🇮", + "description": "liechtenstein", "unicodeVersion": "6.0", "digest": "c4eb4c43f457ce60ff9d046adb512c1d3462203403eeb595bff3ebc010ed6633" }, "flag_lk": { "category": "flags", "moji": "🇱🇰", + "description": "sri lanka", "unicodeVersion": "6.0", "digest": "a5285cdfdc3715fa3941f5f0eb03dc425969eaaf22c719c27ab4418628d09bc5" }, "flag_lr": { "category": "flags", "moji": "🇱🇷", + "description": "liberia", "unicodeVersion": "6.0", "digest": "ed04334264953b4da570db8c392b99d2fab4e0b7efc2331427016c6a08e818be" }, "flag_ls": { "category": "flags", "moji": "🇱🇸", + "description": "lesotho", "unicodeVersion": "6.0", "digest": "cd56022106d027317cc9bf4c848758cf29ffe277ce71fdb9c1cf89ac4fd6e6db" }, "flag_lt": { "category": "flags", "moji": "🇱🇹", + "description": "lithuania", "unicodeVersion": "6.0", "digest": "3c4395b068e421100fd97a102f170cb8d5c093885eef7cb40d3faff4f4e47fe9" }, "flag_lu": { "category": "flags", "moji": "🇱🇺", + "description": "luxembourg", "unicodeVersion": "6.0", "digest": "df15a2c47eecad17e0cc169bdf0d31c6a51eb22de7ca4e70d2431359a33f930d" }, "flag_lv": { "category": "flags", "moji": "🇱🇻", + "description": "latvia", "unicodeVersion": "6.0", "digest": "9b53c6ce23287935200da8ca8a8af78013a4b1572f9821e7e1724cbad248e7e2" }, "flag_ly": { "category": "flags", "moji": "🇱🇾", + "description": "libya", "unicodeVersion": "6.0", "digest": "42efa9f3526ef006d6723fa17538a98ab9556ae25f14df1b06d21361bf7e1a44" }, "flag_ma": { "category": "flags", "moji": "🇲🇦", + "description": "morocco", "unicodeVersion": "6.0", "digest": "96c07296cfd7aa1cb642faed8ace26744105b81ca880157a4ef4caee0befe26e" }, "flag_mc": { "category": "flags", "moji": "🇲🇨", + "description": "monaco", "unicodeVersion": "6.0", "digest": "6b44608842fe849ae2b4bae5eb87ccd436459a427051dfda25080196273d4b9f" }, "flag_md": { "category": "flags", "moji": "🇲🇩", + "description": "moldova", "unicodeVersion": "6.0", "digest": "78c7b01c698873a9129d52ba38b3eb4cfc683ef2ae10b7b922b17c07f1c938c8" }, "flag_me": { "category": "flags", "moji": "🇲🇪", + "description": "montenegro", "unicodeVersion": "6.0", "digest": "01aa0f9df89302edc4ae319b5dd78069ba8807c3f38cc7bfe01bff67c8efd416" }, "flag_mf": { "category": "flags", "moji": "🇲🇫", + "description": "saint martin", "unicodeVersion": "6.0", "digest": "a0124683aa88cd7da886da70c65796c5ad84eb3751e356e9b2aa8ac249cf0bf9" }, "flag_mg": { "category": "flags", "moji": "🇲🇬", + "description": "madagascar", "unicodeVersion": "6.0", "digest": "56ebcd2a2e144d656d3b38a62595138fe6e50f9c1144f70b0a120cce7a72eb5b" }, "flag_mh": { "category": "flags", "moji": "🇲🇭", + "description": "the marshall islands", "unicodeVersion": "6.0", "digest": "008660adc4c2e4d04830498988184d1ef8a372a6c085da369a94ee6b820dbbb7" }, "flag_mk": { "category": "flags", "moji": "🇲🇰", + "description": "macedonia", "unicodeVersion": "6.0", "digest": "f3c4c5106ace81c21fc0c6a7cc5c5e04e9453468fbc6ccbc851bb8dd61ff237f" }, "flag_ml": { "category": "flags", "moji": "🇲🇱", + "description": "mali", "unicodeVersion": "6.0", "digest": "e70a6b30e46adc2e19684308a848fef2c3ad76e2cac4bb493ee3270ad39f9d1b" }, "flag_mm": { "category": "flags", "moji": "🇲🇲", + "description": "myanmar", "unicodeVersion": "6.0", "digest": "720f5d38887202ba049cd5a46c183679be6a01f169d99e6e656c73b515793a7d" }, "flag_mn": { "category": "flags", "moji": "🇲🇳", + "description": "mongolia", "unicodeVersion": "6.0", "digest": "5f0fd6fcb2ed73a5a6d9396c3703612503c1f16283bbb4e9362a1c8324b762ad" }, "flag_mo": { "category": "flags", "moji": "🇲🇴", + "description": "macau", "unicodeVersion": "6.0", "digest": "fc2a9e7323867cf195f551e59afdab778c56b84c96af28c20207c9870caa2c39" }, "flag_mp": { "category": "flags", "moji": "🇲🇵", + "description": "northern mariana islands", "unicodeVersion": "6.0", "digest": "ddce3be9d72914240c42e1b97ea97af01016d0a3879999cb0e447552682c06ba" }, "flag_mq": { "category": "flags", "moji": "🇲🇶", + "description": "martinique", "unicodeVersion": "6.0", "digest": "888f455b1322d6fb83dc9f469f5505fea3dd6ece77d17d0d7345319c3ebcec0e" }, "flag_mr": { "category": "flags", "moji": "🇲🇷", + "description": "mauritania", "unicodeVersion": "6.0", "digest": "72621914c92dd9c9f3ac9973ee3589583bfe42b841cdd35f47af75e2f629726c" }, "flag_ms": { "category": "flags", "moji": "🇲🇸", + "description": "montserrat", "unicodeVersion": "6.0", "digest": "5944996295132f41ec55261ff7927518bd47aec95d274a6ff257c357b43657bc" }, "flag_mt": { "category": "flags", "moji": "🇲🇹", + "description": "malta", "unicodeVersion": "6.0", "digest": "95f0550e8823441a4e69b26c540baea94f3ddcc282100fd0239021c00df0b469" }, "flag_mu": { "category": "flags", "moji": "🇲🇺", + "description": "mauritius", "unicodeVersion": "6.0", "digest": "5fda78a6df0ea7f5cac5fb4c8fd68529c14c5e15bac4e0b167493cb6ac459253" }, "flag_mv": { "category": "flags", "moji": "🇲🇻", + "description": "maldives", "unicodeVersion": "6.0", "digest": "f75c8f6fd3a68f2944a04c833c649d4b576997f491100cf3f3160fe77117fabb" }, "flag_mw": { "category": "flags", "moji": "🇲🇼", + "description": "malawi", "unicodeVersion": "6.0", "digest": "d46b484a97e5b90b6b259f8de1712b553f93f0dfb6391209200358bb9429ebf5" }, "flag_mx": { "category": "flags", "moji": "🇲🇽", + "description": "mexico", "unicodeVersion": "6.0", "digest": "dc57c10307fc0aa09bd7fcd25ee0fca561f3b382276faa8432a927c1baea53fd" }, "flag_my": { "category": "flags", "moji": "🇲🇾", + "description": "malaysia", "unicodeVersion": "6.0", "digest": "15ca00660a1eb0096fdaa00b85a7b95fcf192bf2ee4781ba72c36d2d2cb015ef" }, "flag_mz": { "category": "flags", "moji": "🇲🇿", + "description": "mozambique", "unicodeVersion": "6.0", "digest": "0c8605a9319dcf86672a833b4c4d6acea5f6aa25a3f8e1dfac78fbf7c452ba97" }, "flag_na": { "category": "flags", "moji": "🇳🇦", + "description": "namibia", "unicodeVersion": "6.0", "digest": "e63cde5ee49d3ada1e33d2ab15dc24fbb129b90d65b6fd1d7c07455f71a53601" }, "flag_nc": { "category": "flags", "moji": "🇳🇨", + "description": "new caledonia", "unicodeVersion": "6.0", "digest": "a4a350ce7404ba7bdda9a341e7a48fcfe16312be4964b1bd6eed7115acd2e329" }, "flag_ne": { "category": "flags", "moji": "🇳🇪", + "description": "niger", "unicodeVersion": "6.0", "digest": "6b32483b4445bc52855509f618c570b9c9606de5649e4878b71b44ff2acbc9fd" }, "flag_nf": { "category": "flags", "moji": "🇳🇫", + "description": "norfolk island", "unicodeVersion": "6.0", "digest": "96b1ec33acbd2b1ffe42703c11a2a633b036e6779849b0e6fa8f399167820584" }, "flag_ng": { "category": "flags", "moji": "🇳🇬", + "description": "nigeria", "unicodeVersion": "6.0", "digest": "f97d0630cbfa5e75440251df7529a67b58c22598643390cbeea82fb04a1cd956" }, "flag_ni": { "category": "flags", "moji": "🇳🇮", + "description": "nicaragua", "unicodeVersion": "6.0", "digest": "c52fb5f9134122a91defa75425be2c6b3c909e051d546244e0e7bdf5f9ee1710" }, "flag_nl": { "category": "flags", "moji": "🇳🇱", + "description": "the netherlands", "unicodeVersion": "6.0", "digest": "b8918f9c0c92513aa0ec6ba6cee5448270168cbe6f0a970fb06e7ceb9f52ec71" }, "flag_no": { "category": "flags", "moji": "🇳🇴", + "description": "norway", "unicodeVersion": "6.0", "digest": "05ce84095f8d93407d611b39d8b6a67fd9f11df6cfab7a185bcb4eec186d85ef" }, "flag_np": { "category": "flags", "moji": "🇳🇵", + "description": "nepal", "unicodeVersion": "6.0", "digest": "cc41c2f97ec2b38fe5781d553792f6aab5d37cc3be02586f361fe89d12683bee" }, "flag_nr": { "category": "flags", "moji": "🇳🇷", + "description": "nauru", "unicodeVersion": "6.0", "digest": "7837edf59ec33a25380d76afea5f04cfcab4f17df4e33fca0dcaacb517c5cbec" }, "flag_nu": { "category": "flags", "moji": "🇳🇺", + "description": "niue", "unicodeVersion": "6.0", "digest": "fd9ab45c6f32bc4da47542392e5beba73ddac302a4a9a00e6deedc913a4c087d" }, "flag_nz": { "category": "flags", "moji": "🇳🇿", + "description": "new zealand", "unicodeVersion": "6.0", "digest": "0719830dcca400cefb30ce399bb03f49dd84c9a98f7d6a28270f9278e2a7af75" }, "flag_om": { "category": "flags", "moji": "🇴🇲", + "description": "oman", "unicodeVersion": "6.0", "digest": "3f9039becd52e3454fdf7611cdb0d7fb1196e053eea29ef87daab6c21a94f1ee" }, "flag_pa": { "category": "flags", "moji": "🇵🇦", + "description": "panama", "unicodeVersion": "6.0", "digest": "1adf0e5d4084e072aa44bd9978829e77546e0be75785e9be69f92e326bd714a7" }, "flag_pe": { "category": "flags", "moji": "🇵🇪", + "description": "peru", "unicodeVersion": "6.0", "digest": "f8a4e257676f4ab8962ffe5509b8417777a8be2f0e9dc7735d3e014ff221aab1" }, "flag_pf": { "category": "flags", "moji": "🇵🇫", + "description": "french polynesia", "unicodeVersion": "6.0", "digest": "1ace6cc71d130cdf09246297740a911f14828c322e35330cc548ca5975015c23" }, "flag_pg": { "category": "flags", "moji": "🇵🇬", + "description": "papua new guinea", "unicodeVersion": "6.0", "digest": "9c37719d9f51ef31fec0f898d38e522b4253cd00344408e3f660132514efddb7" }, "flag_ph": { "category": "flags", "moji": "🇵🇭", + "description": "the philippines", "unicodeVersion": "6.0", "digest": "f1af628cf6d1d290cedef3d564b2386e2d6f14ba4426d3fefc0312cb8772e517" }, "flag_pk": { "category": "flags", "moji": "🇵🇰", + "description": "pakistan", "unicodeVersion": "6.0", "digest": "61c77f73d2a10a5acb289fadfe0d25d1a1c343e1223bd802099ff4e0e9356521" }, "flag_pl": { "category": "flags", "moji": "🇵🇱", + "description": "poland", "unicodeVersion": "6.0", "digest": "38c2c8618446e1f72cf983ab33e736d943f0db7c4cce52a187299e8cec2ea895" }, "flag_pm": { "category": "flags", "moji": "🇵🇲", + "description": "saint pierre and miquelon", "unicodeVersion": "6.0", "digest": "656be9ea1a79c3885a759c7ce353d338345a198d7939556949affaf5490cb644" }, "flag_pn": { "category": "flags", "moji": "🇵🇳", + "description": "pitcairn", "unicodeVersion": "6.0", "digest": "2792260d8087ab0253b1214c1420f0160ab2eef9afe7315f9e7ff0b87cd15d72" }, "flag_pr": { "category": "flags", "moji": "🇵🇷", + "description": "puerto rico", "unicodeVersion": "6.0", "digest": "c4cfa1f2201dcda9de310a8247e6ce32d2798ae426a14dd70a9ebb00a2804d46" }, "flag_ps": { "category": "flags", "moji": "🇵🇸", + "description": "palestinian authority", "unicodeVersion": "6.0", "digest": "197f2ec6294bf0ee4a08cf2f2d1e237ee867c98b3085454a3f42abc955eeb289" }, "flag_pt": { "category": "flags", "moji": "🇵🇹", + "description": "portugal", "unicodeVersion": "6.0", "digest": "86a50827963756b5bf471ed9df5b3f2a2058b4c5d778a303414b6b0556e2082b" }, "flag_pw": { "category": "flags", "moji": "🇵🇼", + "description": "palau", "unicodeVersion": "6.0", "digest": "a6321c47a0cd188fbfdf3b55f17a7170c63080d28d50e4f5463eb1ee09af2412" }, "flag_py": { "category": "flags", "moji": "🇵🇾", + "description": "paraguay", "unicodeVersion": "6.0", "digest": "1a169e8d8703c510c5a2265b57dbed2f811b03ec375bcb341ab4cd0b100a9dd6" }, "flag_qa": { "category": "flags", "moji": "🇶🇦", + "description": "qatar", "unicodeVersion": "6.0", "digest": "de6283965cd98a244b7fa6288174f9ff0d8feb497f191f2e4ab3b690138a3d5d" }, "flag_re": { "category": "flags", "moji": "🇷🇪", + "description": "réunion", "unicodeVersion": "6.0", "digest": "260e1b97abc1562e5a73d7e53652ffed8059fc9b1c969741c466f48ec6ab0e80" }, "flag_ro": { "category": "flags", "moji": "🇷🇴", + "description": "romania", "unicodeVersion": "6.0", "digest": "6d648e03955fa2a9fd2bad6f60ec96d3e20ee57f5855f3721a4d4e0c8e99f95c" }, "flag_rs": { "category": "flags", "moji": "🇷🇸", + "description": "serbia", "unicodeVersion": "6.0", "digest": "95cd5e197ed364e403eeb7f1d18a83487d89166910ba8119ea994e5e19d6a7ee" }, "flag_ru": { "category": "flags", "moji": "🇷🇺", + "description": "russia", "unicodeVersion": "6.0", "digest": "a4a81617a59d9eaf3c526431ca6f90ed334a7c1f516bf70cbd3f1fdc6e6103d7" }, "flag_rw": { "category": "flags", "moji": "🇷🇼", + "description": "rwanda", "unicodeVersion": "6.0", "digest": "7a369f60db0876ffef111c319a3e8c9eaed620c875c51b98ed9ad5207b836dca" }, "flag_sa": { "category": "flags", "moji": "🇸🇦", + "description": "saudi arabia", "unicodeVersion": "6.0", "digest": "b249fbfd7ed415943f60bbd841965cf721979f960ccbe09396aebac1eca913d7" }, "flag_sb": { "category": "flags", "moji": "🇸🇧", + "description": "the solomon islands", "unicodeVersion": "6.0", "digest": "526b411260024ea7b6ea6c47f2549345c6cc6960e9a29bfa9aaec0772664d2dc" }, "flag_sc": { "category": "flags", "moji": "🇸🇨", + "description": "the seychelles", "unicodeVersion": "6.0", "digest": "d036b0d068745926120eaf746fa2e4433306e2e14c6b540d0cd6265e34471056" }, "flag_sd": { "category": "flags", "moji": "🇸🇩", + "description": "sudan", "unicodeVersion": "6.0", "digest": "889615bdb9b1f9c59c5f83ed4d22d54a0ed5dd5de263e729c58544cb06c55885" }, "flag_se": { "category": "flags", "moji": "🇸🇪", + "description": "sweden", "unicodeVersion": "6.0", "digest": "f471d80cfff340960a752c8c152ed4fb482df2a3712b0a56dfab31b9b806926a" }, "flag_sg": { "category": "flags", "moji": "🇸🇬", + "description": "singapore", "unicodeVersion": "6.0", "digest": "82f58a09f98593cc87e545f7e5c03d2aedaf82e54e73f71f58c18e994c3085ac" }, "flag_sh": { "category": "flags", "moji": "🇸🇭", + "description": "saint helena", "unicodeVersion": "6.0", "digest": "53914b1fa8c1b4f30bae6c1f6717f138fb4dbf482c3e20e33f7aea4ecfc0438d" }, "flag_si": { "category": "flags", "moji": "🇸🇮", + "description": "slovenia", "unicodeVersion": "6.0", "digest": "65d491daa69f9a11cec9ccc4df3a669f12ef95a5c312137776d4472719940ba3" }, "flag_sj": { "category": "flags", "moji": "🇸🇯", + "description": "svalbard and jan mayen", "unicodeVersion": "6.0", "digest": "bbf6daa6174c6fbbbf541c8274f31b6757c3a16007c2687015ea041fd1e2c6b6" }, "flag_sk": { "category": "flags", "moji": "🇸🇰", + "description": "slovakia", "unicodeVersion": "6.0", "digest": "d4fd03eca5bd3c9fb324ee04fae37c9a2d852bac8335369e3e720ef9b98fff36" }, "flag_sl": { "category": "flags", "moji": "🇸🇱", + "description": "sierra leone", "unicodeVersion": "6.0", "digest": "1455c98c11c248623d82be5484ab1c4dcd1dae449adc393eb1aa2d8c74aa3f02" }, "flag_sm": { "category": "flags", "moji": "🇸🇲", + "description": "san marino", "unicodeVersion": "6.0", "digest": "daec5864ac50c625d7bf49d6c1a170a094cf0d1b9a0bdf62a62406e7ec500a94" }, "flag_sn": { "category": "flags", "moji": "🇸🇳", + "description": "senegal", "unicodeVersion": "6.0", "digest": "4e4d43c467e5eb84c70f535f37f4f468319bd4b06c6ec3db3b54f69efdafd334" }, "flag_so": { "category": "flags", "moji": "🇸🇴", + "description": "somalia", "unicodeVersion": "6.0", "digest": "c1434dca361563a8e3ba88f1ad19c3f6c9cbb8f3ebc17ce128fde2351ff67d0c" }, "flag_sr": { "category": "flags", "moji": "🇸🇷", + "description": "suriname", "unicodeVersion": "6.0", "digest": "f3c6bfee2a052f03d56ba917b88595450cef111ffa9e92c7f39ef8c3c3bd12d1" }, "flag_ss": { "category": "flags", "moji": "🇸🇸", + "description": "south sudan", "unicodeVersion": "6.0", "digest": "c0ed7e4f41206f5363e8ebdc6c3f28080e2f07d99e6fb73c1f6226d83310e69d" }, "flag_st": { "category": "flags", "moji": "🇸🇹", + "description": "sao tome and principe", "unicodeVersion": "6.0", "digest": "b022ae5d6885e28c6e9c83c17dd0c24c731d4f3d5773c49051768cdd4df51330" }, "flag_sv": { "category": "flags", "moji": "🇸🇻", + "description": "el salvador", "unicodeVersion": "6.0", "digest": "5bafdd04d243ee3f3998f4ec0a3d03ff5a3975e771b1f94f89d7713193d7a242" }, "flag_sx": { "category": "flags", "moji": "🇸🇽", + "description": "sint maarten", "unicodeVersion": "6.0", "digest": "fb92e9f514bcc2f7abbd4e146edde50f030c940c833f184618cbb48e56af22bd" }, "flag_sy": { "category": "flags", "moji": "🇸🇾", + "description": "syria", "unicodeVersion": "6.0", "digest": "ee330da644d4ce1fdba98be5eaab5054aed8d91a34ab617199a4b2b77f62a10b" }, "flag_sz": { "category": "flags", "moji": "🇸🇿", + "description": "swaziland", "unicodeVersion": "6.0", "digest": "7fe0c7429efd9682cc39e57f4bba8d1491d301643ba999d57c4e1bc37517ed64" }, "flag_ta": { "category": "flags", "moji": "🇹🇦", + "description": "tristan da cunha", "unicodeVersion": "6.0", "digest": "b47e245a2708072a4dbaf190c9606baa4daf02e51627eeae6f20c3b4c95024c0" }, "flag_tc": { "category": "flags", "moji": "🇹🇨", + "description": "turks and caicos islands", "unicodeVersion": "6.0", "digest": "18cfff14c2503b9d24c91c668583d4a14efb17657d800eca86ae49b547c9da5c" }, "flag_td": { "category": "flags", "moji": "🇹🇩", + "description": "chad", "unicodeVersion": "6.0", "digest": "73d1db3365736915c4cdf9ba9343d9fd78962203b60334e8f3724d4b330b17db" }, "flag_tf": { "category": "flags", "moji": "🇹🇫", + "description": "french southern territories", "unicodeVersion": "6.0", "digest": "3bffeb4bc9ceb9cbb150de88e957b6e46509862ca7d616d5693124af084eb435" }, "flag_tg": { "category": "flags", "moji": "🇹🇬", + "description": "togo", "unicodeVersion": "6.0", "digest": "eb13a0e85baf73326f3ae3bc75e8406eca42000d7e42b0641120e64c0ab7ebaa" }, "flag_th": { "category": "flags", "moji": "🇹🇭", + "description": "thailand", "unicodeVersion": "6.0", "digest": "a4e42efa4bb94e90f3a92ae9ce14affaacd3a142c1e0da40d8cc839500e771fd" }, "flag_tj": { "category": "flags", "moji": "🇹🇯", + "description": "tajikistan", "unicodeVersion": "6.0", "digest": "ff926fa3e86e095683a61c4754355a5b4dd0ecb74393306bd791d130fd1a909d" }, "flag_tk": { "category": "flags", "moji": "🇹🇰", + "description": "tokelau", "unicodeVersion": "6.0", "digest": "3fa732d457ded6c83cd5f73d934f64c4e687eb0cde7c157d2fdcdccaf3b5fb52" }, "flag_tl": { "category": "flags", "moji": "🇹🇱", + "description": "east timor", "unicodeVersion": "6.0", "digest": "0ec2a4d22fb832060693089e518bbe370a4e13bfc28748f110fc13726409f473" }, "flag_tm": { "category": "flags", "moji": "🇹🇲", + "description": "turkmenistan", "unicodeVersion": "6.0", "digest": "b4724aa7ad13352f16a0936e61cbb85f0bd147583fc66597aff7e8ee7cf19c21" }, "flag_tn": { "category": "flags", "moji": "🇹🇳", + "description": "tunisia", "unicodeVersion": "6.0", "digest": "5ab308ffdde40f504d6ee080817bbddbe4f3f4ddb71f508c75e0144a8c8044d9" }, "flag_to": { "category": "flags", "moji": "🇹🇴", + "description": "tonga", "unicodeVersion": "6.0", "digest": "75b7e7198fa42f87986882b8ca251a229afcaa0a1188ae7b9f5ece87dc31a723" }, "flag_tr": { "category": "flags", "moji": "🇹🇷", + "description": "turkey", "unicodeVersion": "6.0", "digest": "9cc48a8f8fa9c17c1627272f68d4740da0e7ce17a2cf8c6b5c08cc9b95e1390c" }, "flag_tt": { "category": "flags", "moji": "🇹🇹", + "description": "trinidad and tobago", "unicodeVersion": "6.0", "digest": "f9e63543121bb3cd2e41bc7b0c2c4ba662bc1cc0520b79fc4e201ec6456fdf59" }, "flag_tv": { "category": "flags", "moji": "🇹🇻", + "description": "tuvalu", "unicodeVersion": "6.0", "digest": "6431e5f06cc7995ae7208c429ecf39339b545854cb6d6b7447f465fe53614dfc" }, "flag_tw": { "category": "flags", "moji": "🇹🇼", + "description": "the republic of china", "unicodeVersion": "6.0", "digest": "8395ab3c6a595023b006518a5345ac3612f2893d3a8f011b7e5802414236b03c" }, "flag_tz": { "category": "flags", "moji": "🇹🇿", + "description": "tanzania", "unicodeVersion": "6.0", "digest": "716181733cd9ac7a8f51a9a64bc5d21020e8112f6768e8c49c4d651a3ee0b8a4" }, "flag_ua": { "category": "flags", "moji": "🇺🇦", + "description": "ukraine", "unicodeVersion": "6.0", "digest": "304570736345e28734f5ff84a2b0481c2bb00bf29d9892bd749b57dec7741e30" }, "flag_ug": { "category": "flags", "moji": "🇺🇬", + "description": "uganda", "unicodeVersion": "6.0", "digest": "a1bafb74c54ee8c92cb025b55aebdb6081eec3fda6a7f86f2ee14d1b801a8e9c" }, "flag_um": { "category": "flags", "moji": "🇺🇲", + "description": "united states minor outlying islands", "unicodeVersion": "6.0", "digest": "b3c9ac72211f481f50cde09e10b92aa03b1ea90abf85418e60a35b84963273ee" }, "flag_us": { "category": "flags", "moji": "🇺🇸", + "description": "united states", "unicodeVersion": "6.0", "digest": "da79f9af0a188178a82e7dc3a62298fa416f4cfbcae432838df1abebca5c0d63" }, "flag_uy": { "category": "flags", "moji": "🇺🇾", + "description": "uruguay", "unicodeVersion": "6.0", "digest": "8348e901d775722497ee911c9c9b4bd767710760c507630a67ecb6d47cc646c7" }, "flag_uz": { "category": "flags", "moji": "🇺🇿", + "description": "uzbekistan", "unicodeVersion": "6.0", "digest": "2a1dc1e9469e01c58ea91f545ef3fe0bdfe5544a73a80407f8960d01b1e5db5c" }, "flag_va": { "category": "flags", "moji": "🇻🇦", + "description": "the vatican city", "unicodeVersion": "6.0", "digest": "0e8134ec94bff032bfc63b0b08587d5298c9b7f31edd5a5b35633ae911434e61" }, "flag_vc": { "category": "flags", "moji": "🇻🇨", + "description": "saint vincent and the grenadines", "unicodeVersion": "6.0", "digest": "e0290e1be72c8939ee6c398f00a107703b21b97d91b9bf465e553ffbf00304a7" }, "flag_ve": { "category": "flags", "moji": "🇻🇪", + "description": "venezuela", "unicodeVersion": "6.0", "digest": "76a6a6c2353def1f984d1a6980831e63f3aea5af2201b574197834e7c203d57a" }, "flag_vg": { "category": "flags", "moji": "🇻🇬", + "description": "british virgin islands", "unicodeVersion": "6.0", "digest": "56fc9317b8dd62cccc60010819f8b895dd4569a9b06368a9250f815c39177b8a" }, "flag_vi": { "category": "flags", "moji": "🇻🇮", + "description": "u.s. virgin islands", "unicodeVersion": "6.0", "digest": "2526a3e13b8ccd301f0763580430898c227bd209e3ce482c7951140b28948375" }, "flag_vn": { "category": "flags", "moji": "🇻🇳", + "description": "vietnam", "unicodeVersion": "6.0", "digest": "0cf6b9896bbe4da8ed7718d0abfd56cef1a8321e26f89d3ad1b48488eaffb7a5" }, "flag_vu": { "category": "flags", "moji": "🇻🇺", + "description": "vanuatu", "unicodeVersion": "6.0", "digest": "9dfa282ce1aafc62beacab76e1fc19a141c8bdeaa30898f69b083067b775d362" }, "flag_wf": { "category": "flags", "moji": "🇼🇫", + "description": "wallis and futuna", "unicodeVersion": "6.0", "digest": "a0124683aa88cd7da886da70c65796c5ad84eb3751e356e9b2aa8ac249cf0bf9" }, "flag_white": { "category": "objects", "moji": "🏳", + "description": "waving white flag", "unicodeVersion": "6.0", "digest": "d9be4b7ceb8309c48f88cfd07a9f7ce6758ea6e620e73293cf14baec03ca381c" }, "flag_ws": { "category": "flags", "moji": "🇼🇸", + "description": "samoa", "unicodeVersion": "6.0", "digest": "53addd0dc304a3c8893389ed227986ef2431828b8c071926aa09f9efd815b649" }, "flag_xk": { "category": "flags", "moji": "🇽🇰", + "description": "kosovo", "unicodeVersion": "6.0", "digest": "eba1a832e489e1c2734e773e685df5d128271fa5559d23c060e68be067bf6469" }, "flag_ye": { "category": "flags", "moji": "🇾🇪", + "description": "yemen", "unicodeVersion": "6.0", "digest": "edfa14266785042b6d5fe0f64fafa630b16a3ee7d010501de7cc8554c959afb0" }, "flag_yt": { "category": "flags", "moji": "🇾🇹", + "description": "mayotte", "unicodeVersion": "6.0", "digest": "472ebc676b5d31dec2ac5e02ce69014a3dd94609d30a95f39f3a752f49c85e8b" }, "flag_za": { "category": "flags", "moji": "🇿🇦", + "description": "south africa", "unicodeVersion": "6.0", "digest": "dad162942a43392b4cff6929bd5cbf58c382a03dbc0e552f03c07ad2d8ff08ce" }, "flag_zm": { "category": "flags", "moji": "🇿🇲", + "description": "zambia", "unicodeVersion": "6.0", "digest": "1521ecaf1d1fdc8c15f0c96a6b04e6d4050f26f943a826b3d3d661f6ded6d438" }, "flag_zw": { "category": "flags", "moji": "🇿🇼", + "description": "zimbabwe", "unicodeVersion": "6.0", "digest": "46d05b597c5c77c8e2dc7bd6d8dd62ebca01bc9c9dc9915dafe694ca56402825" }, "flags": { "category": "objects", "moji": "🎏", + "description": "carp streamer", "unicodeVersion": "6.0", "digest": "f860aa4df587cf140c3e9735bbd101e9fd5a1bfcea42e420d85ac0a9877fa21d" }, "flashlight": { "category": "objects", "moji": "🔦", + "description": "electric torch", "unicodeVersion": "6.0", "digest": "e929bbe76e0fd2dc5bd6476858a0bbc717fd21467710435d35d80efb38033d73" }, "fleur-de-lis": { "category": "symbols", "moji": "⚜", + "description": "fleur-de-lis", "unicodeVersion": "4.1", "digest": "ebf49007f367dc05580e9dab942e93e9dda12fa1dc2caa410ac7f8d8cd55d2a3" }, "floppy_disk": { "category": "objects", "moji": "💾", + "description": "floppy disk", "unicodeVersion": "6.0", "digest": "4ee0b5bba41b9e301ed125d3ee1c263bef171ca499e6e1b89276b09af2bc03a0" }, "flower_playing_cards": { "category": "symbols", "moji": "🎴", + "description": "flower playing cards", "unicodeVersion": "6.0", "digest": "edba47c2e3051b2c7effd98794ec977174052782edcb491daec82a2b0d853869" }, "flushed": { "category": "people", "moji": "😳", + "description": "flushed face", "unicodeVersion": "6.0", "digest": "e759d46bab92af5494d78b6c712c06568759afe397e7828ca0a0de1e3eab0165" }, "fog": { "category": "nature", "moji": "🌫", + "description": "fog", "unicodeVersion": "7.0", "digest": "0cbd4733961d30fe0f40f95dd1f37254aebbef26f82dd18ad2000e799eb2898e" }, "foggy": { "category": "travel", "moji": "🌁", + "description": "foggy", "unicodeVersion": "6.0", "digest": "bc3631a4e9e8473b92e842008937add2cd9ffad5b7d772ce759fb5ff6c0e3dca" }, "football": { "category": "activity", "moji": "🏈", + "description": "american football", "unicodeVersion": "6.0", "digest": "ebd790471c3a28d3077818e3b31d915ffe443e06e299bc5cf0dd2534d080634c" }, "footprints": { "category": "people", "moji": "👣", + "description": "footprints", "unicodeVersion": "6.0", "digest": "85bbf2bc0ae8e6259d83a06f513600095d7fcfc44372670f5b2405d380b78811" }, "fork_and_knife": { "category": "food", "moji": "🍴", + "description": "fork and knife", "unicodeVersion": "6.0", "digest": "f228accd36ddccb4ec636207c19d7185191ec79723b780a1bd5c3d00a4b1ef3b" }, "fork_knife_plate": { "category": "food", "moji": "🍽", + "description": "fork and knife with plate", "unicodeVersion": "7.0", "digest": "ec6be99dac8efd3d145807fa60d2b6d8f6d3c02cb95552b55cc0fac39a4db48e" }, "fountain": { "category": "travel", "moji": "⛲", + "description": "fountain", "unicodeVersion": "5.2", "digest": "87043f9256e1d4615159307fcfd21bf6ae2aba0bada7de2bd50d7d6f2ab82395" }, "four": { "category": "symbols", "moji": "4️⃣", + "description": "keycap digit four", "unicodeVersion": "3.0", "digest": "c2c82a966bbb599aae557d930a4fc42604f2081aa45528872f5caf4942ee79d9" }, "four_leaf_clover": { "category": "nature", "moji": "🍀", + "description": "four leaf clover", "unicodeVersion": "6.0", "digest": "ebee16e86bc9be843dfc72ab5372fb462f06be4486b5b25d7d4cac9b2c8b01c8" }, "fox": { "category": "nature", "moji": "🦊", + "description": "fox face", "unicodeVersion": "9.0", "digest": "e9903cb0396f7e49bdd2c384b38e614c13bfa576b3ecc1ec7b9819e4a40d91d1" }, "frame_photo": { "category": "objects", "moji": "🖼", + "description": "frame with picture", "unicodeVersion": "7.0", "digest": "d5074f748a15055ec1fb812c1e5e169e6e3cc73c522c54be1359b0e26c0fc75c" }, "free": { "category": "symbols", "moji": "🆓", + "description": "squared free", "unicodeVersion": "6.0", "digest": "9973522457158362fc5bdd7da858e6371e28a8403d1ef9e4b6427195c7f72cfa" }, "french_bread": { "category": "food", "moji": "🥖", + "description": "baguette bread", "unicodeVersion": "9.0", "digest": "47518a4312f57207b8e8c38188d4a2bd8b16830a885cfcf2d281cfab50c1bc6e" }, "fried_shrimp": { "category": "food", "moji": "🍤", + "description": "fried shrimp", "unicodeVersion": "6.0", "digest": "0792bdc4484852de970c8f43bc3a1a339dc0e48090ec77d6de97cbfcdd17f9e1" }, "fries": { "category": "food", "moji": "🍟", + "description": "french fries", "unicodeVersion": "6.0", "digest": "47915aea67251d358d91a0e4dc3dcc347155336007d6b931a192be72a743b4e9" }, "frog": { "category": "nature", "moji": "🐸", + "description": "frog face", "unicodeVersion": "6.0", "digest": "d024b2ce771df64040534fb0906737d18b562bc3578dee62c2f25ec03c7caffd" }, "frowning": { "category": "people", "moji": "😦", + "description": "frowning face with open mouth", "unicodeVersion": "6.1", "digest": "c01af48537b0011d313d8f65103e1401fce4f5c0269c68e0e9806926c59acc44" }, "frowning2": { "category": "people", "moji": "☹", + "description": "white frowning face", "unicodeVersion": "1.1", "digest": "6568ee393b950c852d440112e86908c456b89fb7780e27778c5fcec168373fbf" }, "fuelpump": { "category": "travel", "moji": "⛽", + "description": "fuel pump", "unicodeVersion": "5.2", "digest": "105e736469f19911b8bab4ab6d29f949ded4b061b54e3dd763726577d6453095" }, "full_moon": { "category": "nature", "moji": "🌕", + "description": "full moon symbol", "unicodeVersion": "6.0", "digest": "aaa87f4676a5aaa29c1b721a3b582e89db6c1f35a25c52e4b480bd193ef39c43" }, "full_moon_with_face": { "category": "nature", "moji": "🌝", + "description": "full moon with face", "unicodeVersion": "6.0", "digest": "05c4b9c339fcdf81ae67027641522baa99c370d87873ff4c8133b8349e627e33" }, "game_die": { "category": "activity", "moji": "🎲", + "description": "game die", "unicodeVersion": "6.0", "digest": "00d19ce8e21dba2cdfeb18709fa8741f3af9d6207f81d5657b68e05e64f105a8" }, "gear": { "category": "objects", "moji": "⚙", + "description": "gear", "unicodeVersion": "4.1", "digest": "c5ba354c0f7a36dce95477091984e352ecc59af8c9f26a94ad8e296dc042b9de" }, "gem": { "category": "objects", "moji": "💎", + "description": "gem stone", "unicodeVersion": "6.0", "digest": "180e66f19d9285e02d0a5e859722c608206826e80323942b9938fc49d44973b1" }, "gemini": { "category": "symbols", "moji": "♊", + "description": "gemini", "unicodeVersion": "1.1", "digest": "278239c598d490a110f1f3f52fc3b85259be8e76034b38228ef3f68d7ddd8cdd" }, "ghost": { "category": "people", "moji": "👻", + "description": "ghost", "unicodeVersion": "6.0", "digest": "80d528fcf8ef9198631527547e43a608a4332a799f9e5550b8318dec67c9c4d2" }, "gift": { "category": "objects", "moji": "🎁", + "description": "wrapped present", "unicodeVersion": "6.0", "digest": "4061a84a59f0300473299678c43e533341eb965db09597fffc6e221fd7b77376" }, "gift_heart": { "category": "symbols", "moji": "💝", + "description": "heart with ribbon", "unicodeVersion": "6.0", "digest": "5420199b515b9b32c964a3c19d87e07461639e3068a939dae26c6436335c0cee" }, "girl": { "category": "people", "moji": "👧", + "description": "girl", "unicodeVersion": "6.0", "digest": "8d2d0b72a91e6e44921b71030ffc4c89c0f50f1364787784afe1e7e568cf1bc6" }, "girl_tone1": { "category": "people", "moji": "👧🏻", + "description": "girl tone 1", "unicodeVersion": "8.0", "digest": "bda12a6b38994a578ee65166bbdd93ea04df4101697b52ed236de8d687df09de" }, "girl_tone2": { "category": "people", "moji": "👧🏼", + "description": "girl tone 2", "unicodeVersion": "8.0", "digest": "de7a0925c30b7181a289f71b1a849c1b7751ee8c104e8f2029bd9c2fe3f91c64" }, "girl_tone3": { "category": "people", "moji": "👧🏽", + "description": "girl tone 3", "unicodeVersion": "8.0", "digest": "e41272816db0e642d003dce7cb262e1593a592251f46729f7830f4515149e1f2" }, "girl_tone4": { "category": "people", "moji": "👧🏾", + "description": "girl tone 4", "unicodeVersion": "8.0", "digest": "8d6a4513ecbf08408c0ecc5336767777a2216f7a19437faf9e51f65101822469" }, "girl_tone5": { "category": "people", "moji": "👧🏿", + "description": "girl tone 5", "unicodeVersion": "8.0", "digest": "f55e4b16a41b6f5e3c817a301420360ba4486e4e82e1092a56a3e3cc4069087d" }, "globe_with_meridians": { "category": "symbols", "moji": "🌐", + "description": "globe with meridians", "unicodeVersion": "6.0", "digest": "725bebeb3c09a9e3701ebe49e672dcfbf2b73575e05f0821263511577b013b75" }, "goal": { "category": "activity", "moji": "🥅", + "description": "goal net", "unicodeVersion": "9.0", "digest": "7088c432f276ff6f447dc0d431b9062b394fb401de1072fe59ca56267bfd6717" }, "goat": { "category": "nature", "moji": "🐐", + "description": "goat", "unicodeVersion": "6.0", "digest": "d07e384d08529ddcaddd2710f2ad913e5665dc15d5f99c28e16dadd245a111e8" }, "golf": { "category": "activity", "moji": "⛳", + "description": "flag in hole", "unicodeVersion": "5.2", "digest": "eed79364754eec97855e3c7b584f347ae139d9ddb4eb7fb66c00867610b8f1c1" }, "golfer": { "category": "activity", "moji": "🏌", + "description": "golfer", "unicodeVersion": "7.0", "digest": "7d7ecc6e226596f646030a4109c2b0001ef0cc690e4863e450bf5d29e7a90344" }, "gorilla": { "category": "nature", "moji": "🦍", + "description": "gorilla", "unicodeVersion": "9.0", "digest": "4a564dc14f8ae5450d094f6410ec7f099a7f07dc5254b6395f44a35527bdb4b7" }, "grapes": { "category": "food", "moji": "🍇", + "description": "grapes", "unicodeVersion": "6.0", "digest": "74d1a09ab411234a84d025a2e717e7ec5791bc02aad29853896d21c0f0283c50" }, "green_apple": { "category": "food", "moji": "🍏", + "description": "green apple", "unicodeVersion": "6.0", "digest": "457490e9b2b20894f50768262d63f1021717079da104d4847076b3fa779e9a21" }, "green_book": { "category": "objects", "moji": "📗", + "description": "green book", "unicodeVersion": "6.0", "digest": "370f635b200efe5e4a9f17da58bd22500e258e61d17795cef375f19c9a45468f" }, "green_heart": { "category": "symbols", "moji": "💚", + "description": "green heart", "unicodeVersion": "6.0", "digest": "f71e30416d9019873f2ed38ef375c48386424ff60b5a07b89b15dc9e0a3970f9" }, "grey_exclamation": { "category": "symbols", "moji": "❕", + "description": "white exclamation mark ornament", "unicodeVersion": "6.0", "digest": "2fa1d356e12c17cc4025e43afb6c3070385f677102a35223302fda46c47a9b03" }, "grey_question": { "category": "symbols", "moji": "❔", + "description": "white question mark ornament", "unicodeVersion": "6.0", "digest": "e1035bcbf0f66d238ef478ba451f5cf2c51627fbf101ed03bad3b2bf38db8aa2" }, "grimacing": { "category": "people", "moji": "😬", + "description": "grimacing face", "unicodeVersion": "6.1", "digest": "2cedad13b8b2a1d4385ca6fa88a251eb7757a4c65dd6d362267864a01247846b" }, "grin": { "category": "people", "moji": "😁", + "description": "grinning face with smiling eyes", "unicodeVersion": "6.0", "digest": "634b2f37e32e57ed6edc7f371993a92e34137dd21ba393de5227cfbbe2422815" }, "grinning": { "category": "people", "moji": "😀", + "description": "grinning face", "unicodeVersion": "6.1", "digest": "cef76aa41771db9fd1d6bd9b4233c22c1fb1931494af54cab29e6347ed9b678d" }, "guardsman": { "category": "people", "moji": "💂", + "description": "guardsman", "unicodeVersion": "6.0", "digest": "17bc7fad6b8c8dbd015bb709380d129f8b8e1e971062d15e6ab0b2e63e500564" }, "guardsman_tone1": { "category": "people", "moji": "💂🏻", + "description": "guardsman tone 1", "unicodeVersion": "8.0", "digest": "c531ecb101bdf9ce1db18e1567882e6db927410237100b0a2492a1401860246e" }, "guardsman_tone2": { "category": "people", "moji": "💂🏼", + "description": "guardsman tone 2", "unicodeVersion": "8.0", "digest": "602168c5204af0f1de8b4aa5863b192ef20c19d263999377aa5eb60f98311732" }, "guardsman_tone3": { "category": "people", "moji": "💂🏽", + "description": "guardsman tone 3", "unicodeVersion": "8.0", "digest": "d0a85de46dd02c7bd6cb14bff0f22d2db9083d4b171a8806c83363b49f3dd9ef" }, "guardsman_tone4": { "category": "people", "moji": "💂🏾", + "description": "guardsman tone 4", "unicodeVersion": "8.0", "digest": "1c9d4d72b6b50bdac8271613b6d2a38340ec2067bc344e8ee2a3c863fd5c23a1" }, "guardsman_tone5": { "category": "people", "moji": "💂🏿", + "description": "guardsman tone 5", "unicodeVersion": "8.0", "digest": "9899a796d01842e495d716fbe737a16d85724f7d3e23f50807ec2bc70f057318" }, "guitar": { "category": "activity", "moji": "🎸", + "description": "guitar", "unicodeVersion": "6.0", "digest": "a1027ceae4dd3ea270740587c9d373329e5677e375c9e00af6ae3275e0b67500" }, "gun": { "category": "objects", "moji": "🔫", + "description": "pistol", "unicodeVersion": "6.0", "digest": "fc12b577df2283e7b336f23774f9cfe5b79f1d26ddd28a64a560519b28d94ca5" }, "haircut": { "category": "people", "moji": "💇", + "description": "haircut", "unicodeVersion": "6.0", "digest": "b243a04f5ca889accd45e7abe095ac5caa92274ed95103f5966a36b415fff412" }, "haircut_tone1": { "category": "people", "moji": "💇🏻", + "description": "haircut tone 1", "unicodeVersion": "8.0", "digest": "a58d0cff1427b80dfd7a9ea5267b4a181e9faaac6a51a0165db522f668b4cf91" }, "haircut_tone2": { "category": "people", "moji": "💇🏼", + "description": "haircut tone 2", "unicodeVersion": "8.0", "digest": "675083ff40001405f8de99268477d50dd8594ff6ca40ddfd442dd42ad76e8216" }, "haircut_tone3": { "category": "people", "moji": "💇🏽", + "description": "haircut tone 3", "unicodeVersion": "8.0", "digest": "70d7581e49c315a3771dd61a3713229886db32aaaeb3af078a69cc042f809150" }, "haircut_tone4": { "category": "people", "moji": "💇🏾", + "description": "haircut tone 4", "unicodeVersion": "8.0", "digest": "ec5e3e909eb3bc375ef9cc0fe0e0f90b33f44f273ada91ccf415bbc43b8ffbfc" }, "haircut_tone5": { "category": "people", "moji": "💇🏿", + "description": "haircut tone 5", "unicodeVersion": "8.0", "digest": "7c89739ee458546a808fded7f96d9354c47a76883ebb262d5f5abeafd021260e" }, "hamburger": { "category": "food", "moji": "🍔", + "description": "hamburger", "unicodeVersion": "6.0", "digest": "48204235238bd89d3a69f319f65135102f3d6b181eec241d4d86b302bbffa9bf" }, "hammer": { "category": "objects", "moji": "🔨", + "description": "hammer", "unicodeVersion": "6.0", "digest": "d0e7830539d935fcd82820c4e0c1d724f0756dfc83a51171fe0f4b36b69fac42" }, "hammer_pick": { "category": "objects", "moji": "⚒", + "description": "hammer and pick", "unicodeVersion": "4.1", "digest": "aa0445f43bca58d17afa7f3577632ca7775f5a28336385b3020b268b15b18142" }, "hamster": { "category": "nature", "moji": "🐹", + "description": "hamster face", "unicodeVersion": "6.0", "digest": "a7e7582e8b1bccd5b7df27ccb05e353a3f0e39bdeb40877732706b9d74a70de1" }, "hand_splayed": { "category": "people", "moji": "🖐", + "description": "raised hand with fingers splayed", "unicodeVersion": "7.0", "digest": "c51a30cb7e575d29ffed16780a6c95ae3f300b8ac523012f4a6e116d68c1fd15" }, "hand_splayed_tone1": { "category": "people", "moji": "🖐🏻", + "description": "raised hand with fingers splayed tone 1", "unicodeVersion": "8.0", "digest": "c31fb44a982ed8808e1c311ec1b0b9c5afcb47f16bb1fc731dc483adf8f0d049" }, "hand_splayed_tone2": { "category": "people", "moji": "🖐🏼", + "description": "raised hand with fingers splayed tone 2", "unicodeVersion": "8.0", "digest": "56a236881184e9ffad54613fa08a67368c432af738f5254fb1cd87b20368acdf" }, "hand_splayed_tone3": { "category": "people", "moji": "🖐🏽", + "description": "raised hand with fingers splayed tone 3", "unicodeVersion": "8.0", "digest": "9242ca97dfd2bbc1947228f6535029afb31f8feb72c14ff4b7f2deea30217425" }, "hand_splayed_tone4": { "category": "people", "moji": "🖐🏾", + "description": "raised hand with fingers splayed tone 4", "unicodeVersion": "8.0", "digest": "43348d9fd3d43b3c45cebaf663bf181bcad3b6df841a5aeed838180db2cdd481" }, "hand_splayed_tone5": { "category": "people", "moji": "🖐🏿", + "description": "raised hand with fingers splayed tone 5", "unicodeVersion": "8.0", "digest": "4b3a0aba7829772fec09f26d6facc19a2f822d2998015297b18b5cab85190ee2" }, "handbag": { "category": "people", "moji": "👜", + "description": "handbag", "unicodeVersion": "6.0", "digest": "45410a3eed0c2e3f68748d7649fa9e33a90f4e80d5291206bdd0b40380c6da45" }, "handball": { "category": "activity", "moji": "🤾", + "description": "handball", "unicodeVersion": "9.0", "digest": "94ceb28024eb3259d8b137cafd7438773e717fbc04f5da810f85e43ca0fa9e00" }, "handball_tone1": { "category": "activity", "moji": "🤾🏻", + "description": "handball tone 1", "unicodeVersion": "9.0", "digest": "8bec4de0d05c80e335e44d65598d186ca92696977353c9fd9c2a5efa122cb842" }, "handball_tone2": { "category": "activity", "moji": "🤾🏼", + "description": "handball tone 2", "unicodeVersion": "9.0", "digest": "2ff4131e1e2f089b315d8e176c9348877c26c2bd03706fb75d41bc61bc99bf93" }, "handball_tone3": { "category": "activity", "moji": "🤾🏽", + "description": "handball tone 3", "unicodeVersion": "9.0", "digest": "224a71f94dd37d3729325d11412334667a81422e21f6d7c008730ff350f51a80" }, "handball_tone4": { "category": "activity", "moji": "🤾🏾", + "description": "handball tone 4", "unicodeVersion": "9.0", "digest": "a5f7a9db790565981bad2d0d9e09554c8c509a8179b4705a418300d58a7894b4" }, "handball_tone5": { "category": "activity", "moji": "🤾🏿", + "description": "handball tone 5", "unicodeVersion": "9.0", "digest": "00404572d4683f2e8e8a494aa733e96fbec1723634d0a8cb8d75f2829a789d27" }, "handshake": { "category": "people", "moji": "🤝", + "description": "handshake", "unicodeVersion": "9.0", "digest": "cb4b08b70560908f96bda0aecd2f4c966bea180f9b7200e4c81d342dc8d36087" }, "handshake_tone1": { "category": "people", "moji": "🤝🏻", + "description": "handshake tone 1", "unicodeVersion": "9.0", "digest": "40470e224683ba375ed8698c0cbd560556be5a8898237ddf504377a3a7e89ff0" }, "handshake_tone2": { "category": "people", "moji": "🤝🏼", + "description": "handshake tone 2", "unicodeVersion": "9.0", "digest": "77ed378243bf682f1f4f1a8caeabcbedf772f54631cc40ea46c099e46a499b18" }, "handshake_tone3": { "category": "people", "moji": "🤝🏽", + "description": "handshake tone 3", "unicodeVersion": "9.0", "digest": "81b95050f0878b617f5d2640e34031c26a0072e46ca5a688eb4356e48bc74c92" }, "handshake_tone4": { "category": "people", "moji": "🤝🏾", + "description": "handshake tone 4", "unicodeVersion": "9.0", "digest": "74919a6f026fbbd0ccdbdbd4288d1b2ef3bda8930e9142c07736db4a7f3ef345" }, "handshake_tone5": { "category": "people", "moji": "🤝🏿", + "description": "handshake tone 5", "unicodeVersion": "9.0", "digest": "a30d662bfad0074ca7e32cf6f7229b643b636c4beaec496777eb7e1d5b6fc470" }, "hash": { "category": "symbols", "moji": "#⃣", + "description": "number sign", "unicodeVersion": "3.0", "digest": "01c8b577953010bff0c20f797c2c96ab5d98d4e6ac179c4895a78f34ea904655" }, "hatched_chick": { "category": "nature", "moji": "🐥", + "description": "front-facing baby chick", "unicodeVersion": "6.0", "digest": "006571b9e9e839ec9fcb1a911b935c8ca71eb8bcdce9775bee6a2a4c7c927277" }, "hatching_chick": { "category": "nature", "moji": "🐣", + "description": "hatching chick", "unicodeVersion": "6.0", "digest": "fd7f69fa186407f80de59dec5116e318325a5743ee0e8bba1db541f1e57e7f74" }, "head_bandage": { "category": "people", "moji": "🤕", + "description": "face with head-bandage", "unicodeVersion": "8.0", "digest": "d09019a73e203b38cc43729a96163147de88e09eab8adb073888e55366854c72" }, "headphones": { "category": "activity", "moji": "🎧", + "description": "headphone", "unicodeVersion": "6.0", "digest": "34f9d5598158d5d6f978a5ea5c5aa9948bb2990625565a3afad7710f864fbe2f" }, "hear_no_evil": { "category": "nature", "moji": "🙉", + "description": "hear-no-evil monkey", "unicodeVersion": "6.0", "digest": "53b030b6d6f4ed1a734fa7d48b46f42eb1b2b01653202c1838b742082f08c4bf" }, "heart": { "category": "symbols", "moji": "❤", + "description": "heavy black heart", "unicodeVersion": "1.1", "digest": "92be652ec3e50c6e7393440b5d52b88a367f98a28dffe12660095ed3253aa6c0" }, "heart_decoration": { "category": "symbols", "moji": "💟", + "description": "heart decoration", "unicodeVersion": "6.0", "digest": "6ec5bbf3aa75c6f43eb3dc05e9204366936e8b6b4219310bacdc2fc45f51e245" }, "heart_exclamation": { "category": "symbols", "moji": "❣", + "description": "heavy heart exclamation mark ornament", "unicodeVersion": "1.1", "digest": "5985ea4d82232a2a07052a59db268aed9ac943895d0c82f637595bb5386329a6" }, "heart_eyes": { "category": "people", "moji": "😍", + "description": "smiling face with heart-shaped eyes", "unicodeVersion": "6.0", "digest": "0eff616517a6252ec89d47d9b4ad85589bcf2bdc7f490578934350acb84b2fcc" }, "heart_eyes_cat": { "category": "people", "moji": "😻", + "description": "smiling cat face with heart-shaped eyes", "unicodeVersion": "6.0", "digest": "8a1f28b97d661ca4cff5ee13889ca61b5fa745ccb590e80832b7d7701df101d6" }, "heartbeat": { "category": "symbols", "moji": "💓", + "description": "beating heart", "unicodeVersion": "6.0", "digest": "c9ec024943439d476df6f5ec3a6b30508365a7af3427671a80de3ef2f4f95ffe" }, "heartpulse": { "category": "symbols", "moji": "💗", + "description": "growing heart", "unicodeVersion": "6.0", "digest": "281d8aebfea37db5b7fe82d9115be167006881fe29ab64a5b09ac92ac27a2309" }, "hearts": { "category": "symbols", "moji": "♥", + "description": "black heart suit", "unicodeVersion": "1.1", "digest": "271429d12c40be921897005b7bdd08f9518960af1e1e6f56bb0060f1f183651e" }, "heavy_check_mark": { "category": "symbols", "moji": "✔", + "description": "heavy check mark", "unicodeVersion": "1.1", "digest": "e347728e1290eb9e7b0742d628e2fd124fc049e0774f8a6ddf8e5286e7318718" }, "heavy_division_sign": { "category": "symbols", "moji": "➗", + "description": "heavy division sign", "unicodeVersion": "6.0", "digest": "c1e8c40f0788f140b1c5fcb81ed9b5ce1bcfa5988bb8140ed2808e9cb7e0d651" }, "heavy_dollar_sign": { "category": "symbols", "moji": "💲", + "description": "heavy dollar sign", "unicodeVersion": "6.0", "digest": "7cdeef38348654b93d566e01a48973281cb404a63d0b75b3bad51032887f3f55" }, "heavy_minus_sign": { "category": "symbols", "moji": "➖", + "description": "heavy minus sign", "unicodeVersion": "6.0", "digest": "e5335cc6b22abdce49a6127c34269b65a4a6643ddd3253d9baac425089143e7d" }, "heavy_multiplication_x": { "category": "symbols", "moji": "✖", + "description": "heavy multiplication x", "unicodeVersion": "1.1", "digest": "64bbe9e9716a922e405d2f6d3b6d803863a53fac80ff8cd775899971046cb1ca" }, "heavy_plus_sign": { "category": "symbols", "moji": "➕", + "description": "heavy plus sign", "unicodeVersion": "6.0", "digest": "d0d8ade2020ceb252205180b85c66e665856e6cb505518d395b9913b0b24b746" }, "helicopter": { "category": "travel", "moji": "🚁", + "description": "helicopter", "unicodeVersion": "6.0", "digest": "4bd6fd13650fbe3a19cfffeffe6c21b1cda74bd6af64c5dc5999185e35444bc3" }, "helmet_with_cross": { "category": "people", "moji": "⛑", + "description": "helmet with white cross", "unicodeVersion": "5.2", "digest": "8286107391d44b9cd7fce5dc83bfdebbcdcf5a8214c46a8990732ec40263ed77" }, "herb": { "category": "nature", "moji": "🌿", + "description": "herb", "unicodeVersion": "6.0", "digest": "9fe8ed65515ede59d0926dcf98f14e2498785e1965610aa0dd56eca9b4bedad9" }, "hibiscus": { "category": "nature", "moji": "🌺", + "description": "hibiscus", "unicodeVersion": "6.0", "digest": "c442e8eacbd8727bd154bd39692a9a2a03ea2f674b9670ad8361f78a038afe49" }, "high_brightness": { "category": "symbols", "moji": "🔆", + "description": "high brightness symbol", "unicodeVersion": "6.0", "digest": "35ced42426dcfd5214c2c6c577dce84bb708156433945e6b6adaff7ea530cc57" }, "high_heel": { "category": "people", "moji": "👠", + "description": "high-heeled shoe", "unicodeVersion": "6.0", "digest": "1e7c7aba50eb1d02cf1d9aa372caca741a6005cf47f68dfa75b7310c3cb18f05" }, "hockey": { "category": "activity", "moji": "🏒", + "description": "ice hockey stick and puck", "unicodeVersion": "8.0", "digest": "2d00fb17baa617e799db8e9b1771cc365bb4545c7633df0123e66e1a6e2ed25d" }, "hole": { "category": "objects", "moji": "🕳", + "description": "hole", "unicodeVersion": "7.0", "digest": "8b5539f6f24f09d5d68ffd56be5aa2a8a2f753a8dfbf64892fb02c8f2703e920" }, "homes": { "category": "travel", "moji": "🏘", + "description": "house buildings", "unicodeVersion": "7.0", "digest": "cd512f2b4ce747325607d47da48e083dbfe38a44b85b2522bc372bd105afd25f" }, "honey_pot": { "category": "food", "moji": "🍯", + "description": "honey pot", "unicodeVersion": "6.0", "digest": "f6eec8c32fbd1b461446dc6c5d5031c43e6ee9685dc9b1ea1b839114e48c4eee" }, "horse": { "category": "nature", "moji": "🐴", + "description": "horse face", "unicodeVersion": "6.0", "digest": "e377649a9549835770a2a721a92570f699255f88efa646029638eb8ec5f10e3d" }, "horse_racing": { "category": "activity", "moji": "🏇", + "description": "horse racing", "unicodeVersion": "6.0", "digest": "3b98e94e9c028ad85b9a750cc61db5ee3ac23cf5ad9243ea3e996b1f772bad54" }, "horse_racing_tone1": { "category": "activity", "moji": "🏇🏻", + "description": "horse racing tone 1", "unicodeVersion": "8.0", "digest": "382d8e4502ed34fc1bbf1779ce483bc2e22b83f89c91746c11a5d7aea656d446" }, "horse_racing_tone2": { "category": "activity", "moji": "🏇🏼", + "description": "horse racing tone 2", "unicodeVersion": "8.0", "digest": "198df9973b492ea63e5cfc210dd9591750ccce04a6380adc1dc5b4cb0462a8cd" }, "horse_racing_tone3": { "category": "activity", "moji": "🏇🏽", + "description": "horse racing tone 3", "unicodeVersion": "8.0", "digest": "a67f95fc92c366750ebad3c4db92982893d67a5ed78163c8cc809ac40d2ab9a3" }, "horse_racing_tone4": { "category": "activity", "moji": "🏇🏾", + "description": "horse racing tone 4", "unicodeVersion": "8.0", "digest": "986b1706c4a3395b58a8ae3b7609ffdd4424dfefcbf26c88c8085f4f6379734e" }, "horse_racing_tone5": { "category": "activity", "moji": "🏇🏿", + "description": "horse racing tone 5", "unicodeVersion": "8.0", "digest": "66656b5e3d0f43f16f983f9db6214b07aac73b143eeff6475782f98aa5b9ba53" }, "hospital": { "category": "travel", "moji": "🏥", + "description": "hospital", "unicodeVersion": "6.0", "digest": "034573e76df444f5b0eb7aff3a4103e4b49a1813869155ab3ae29a6fc0c6c8a2" }, "hot_pepper": { "category": "food", "moji": "🌶", + "description": "hot pepper", "unicodeVersion": "7.0", "digest": "0b05777d42698196a10db17d04030175b1dfa772d06288f71d666d5f8d3fddbc" }, "hotdog": { "category": "food", "moji": "🌭", + "description": "hot dog", "unicodeVersion": "8.0", "digest": "7a25bbd1a7531fd34a22c654c0931d9e74bea2bbe7baa9f9cbd88f43baa79fb5" }, "hotel": { "category": "travel", "moji": "🏨", + "description": "hotel", "unicodeVersion": "6.0", "digest": "2d78e0ad4cfb0caad778c7de49fefd6e8356afe902a43e3f1c40bceb6b0be422" }, "hotsprings": { "category": "symbols", "moji": "♨", + "description": "hot springs", "unicodeVersion": "1.1", "digest": "4c10c3a974b44693e8cbe91365c8b8d7f14f62db234cc516b6e54c08a6bacaed" }, "hourglass": { "category": "objects", "moji": "⌛", + "description": "hourglass", "unicodeVersion": "1.1", "digest": "f0bae8392aaf6f75a83f5d8914936b8650665b24ba1b232fa546b71545dd9acd" }, "hourglass_flowing_sand": { "category": "objects", "moji": "⏳", + "description": "hourglass with flowing sand", "unicodeVersion": "6.0", "digest": "2d077729f40fc04007a933e97356bd511cbd8be76b8c55962ca3fa0d8b828e23" }, "house": { "category": "travel", "moji": "🏠", + "description": "house building", "unicodeVersion": "6.0", "digest": "b4ac25979fbe161ada0d2a75769aa7552d2371d37d78cddba4ffdc7f076d3279" }, "house_abandoned": { "category": "travel", "moji": "🏚", + "description": "derelict house building", "unicodeVersion": "7.0", "digest": "6e1a58533fbfe88a0eb03668c9f17c5c654a6cc7734ed798d4a885400f823610" }, "house_with_garden": { "category": "travel", "moji": "🏡", + "description": "house with garden", "unicodeVersion": "6.0", "digest": "817463f23ec0a849393ba75c333e822b4d253cd4db998c127e90d1b924f35d20" }, "hugging": { "category": "people", "moji": "🤗", + "description": "hugging face", "unicodeVersion": "8.0", "digest": "69810a98b1247e1f1e496aa757e428189ef5cc086764fabd8189cf1eef82234f" }, "hushed": { "category": "people", "moji": "😯", + "description": "hushed face", "unicodeVersion": "6.1", "digest": "22586107f7399eff64538a52929dade152633aa268fc5ec4e6fe1c0e00a7bd89" }, "ice_cream": { "category": "food", "moji": "🍨", + "description": "ice cream", "unicodeVersion": "6.0", "digest": "d1a8e685f2ecf83dead28733859e369d6ce120a2669cdab97dc4423547d472ac" }, "ice_skate": { "category": "activity", "moji": "⛸", + "description": "ice skate", "unicodeVersion": "5.2", "digest": "41ef65c143bc068868fa64080ffd447d91aa3fe2a39e69ecaa97022820af4dcd" }, "icecream": { "category": "food", "moji": "🍦", + "description": "soft ice cream", "unicodeVersion": "6.0", "digest": "22cfe17b80cbd2a0377ee90da45bd40d33533c914b2639d363fbb1f00714e194" }, "id": { "category": "symbols", "moji": "🆔", + "description": "squared id", "unicodeVersion": "6.0", "digest": "bcf0922e083821d3be7951893084ea0d72a0110ef0b20d11dfec24dd70633893" }, "ideograph_advantage": { "category": "symbols", "moji": "🉐", + "description": "circled ideograph advantage", "unicodeVersion": "6.0", "digest": "0b6bf59f63fda1afa92d652814a778a056c3f4abdd9cf3f6796068bd71783051" }, "imp": { "category": "people", "moji": "👿", + "description": "imp", "unicodeVersion": "6.0", "digest": "52598cf2441988f875ccb4e479637baefc679e3ca64e9a6400e56488b0fde811" }, "inbox_tray": { "category": "objects", "moji": "📥", + "description": "inbox tray", "unicodeVersion": "6.0", "digest": "d5d9497022b5318fcfbfdfcd56df9c65dd8f4a4cb5e6283ca260836df57da301" }, "incoming_envelope": { "category": "objects", "moji": "📨", + "description": "incoming envelope", "unicodeVersion": "6.0", "digest": "310b7bdcca93452fe10c72c03d0aafa12b98e5d3408896d275d06d3693812c7a" }, "information_desk_person": { "category": "people", "moji": "💁", + "description": "information desk person", "unicodeVersion": "6.0", "digest": "9f12a4a58a650e8e1d3836ef857003c3ccd42ad4203a2479eb95100bf6559064" }, "information_desk_person_tone1": { "category": "people", "moji": "💁🏻", + "description": "information desk person tone 1", "unicodeVersion": "8.0", "digest": "6674f2e059eff7cfd7fd6abc800da37c4f1087feb4ff26c9e4e31aa29fdf9921" }, "information_desk_person_tone2": { "category": "people", "moji": "💁🏼", + "description": "information desk person tone 2", "unicodeVersion": "8.0", "digest": "9983412ecd130b7e9cfb078167016c06fd043b6f9f3c26d21733ca3f059fd109" }, "information_desk_person_tone3": { "category": "people", "moji": "💁🏽", + "description": "information desk person tone 3", "unicodeVersion": "8.0", "digest": "d8907bf47af5722127afca8fc0da587eab33044a6c60a94890983deb8d6f7a66" }, "information_desk_person_tone4": { "category": "people", "moji": "💁🏾", + "description": "information desk person tone 4", "unicodeVersion": "8.0", "digest": "3be086d4edfe9ca8e4a364b4e8d09b81b5b594b5eeb9ffdf6370179fb3118658" }, "information_desk_person_tone5": { "category": "people", "moji": "💁🏿", + "description": "information desk person tone 5", "unicodeVersion": "8.0", "digest": "2fde4e98dd11c5c29c89cad7cbb7bd2d5077dfad07913b20e01955b2d0dfad40" }, "information_source": { "category": "symbols", "moji": "ℹ", + "description": "information source", "unicodeVersion": "3.0", "digest": "b6bf3cce86d42c2e3c46470baab4af01e900b8ae337b605c3da07c3eba671269" }, "innocent": { "category": "people", "moji": "😇", + "description": "smiling face with halo", "unicodeVersion": "6.0", "digest": "20f8d856bc3e46f4b1173cea05d4577e1c61f06b2daba46e57db90f4066bb428" }, "interrobang": { "category": "symbols", "moji": "⁉", + "description": "exclamation question mark", "unicodeVersion": "3.0", "digest": "92a2d5b4c0bd6714e402f6f12fe19774cb41d081b5e9c23c415ce794224d8117" }, "iphone": { "category": "objects", "moji": "📱", + "description": "mobile phone", "unicodeVersion": "6.0", "digest": "1ebc54215713cd4bf1c1e50770999f2512bb4fea29e37d0bb3a8aa2460ff875d" }, "island": { "category": "travel", "moji": "🏝", + "description": "desert island", "unicodeVersion": "7.0", "digest": "7f9eb5c0cd865762f7a0f187e09c1be442de7010e7c2e113d56aae998597c90d" }, "izakaya_lantern": { "category": "objects", "moji": "🏮", + "description": "izakaya lantern", "unicodeVersion": "6.0", "digest": "fbdc290e666d43d0776a73b955c26df4518692b35e72742e073705fc4ca2ae88" }, "jack_o_lantern": { "category": "nature", "moji": "🎃", + "description": "jack-o-lantern", "unicodeVersion": "6.0", "digest": "78d666c2e80f64bfb6796f53e5ba4960a83ec36192110e8661031bee2b5e370a" }, "japan": { "category": "travel", "moji": "🗾", + "description": "silhouette of japan", "unicodeVersion": "6.0", "digest": "e7d9d6ebf9047fdd3c52e074ba259659c6d8e51a6abae3cdb8d6cf6dbf9a93fe" }, "japanese_castle": { "category": "travel", "moji": "🏯", + "description": "japanese castle", "unicodeVersion": "6.0", "digest": "938ae132c403330288223b88d28c19a47224d4f254fbc2366ecef73d9633112c" }, "japanese_goblin": { "category": "people", "moji": "👺", + "description": "japanese goblin", "unicodeVersion": "6.0", "digest": "63d4bcf58b9d0c29612994432aad2ae35819fdd2890674e60a2f1d51601b742e" }, "japanese_ogre": { "category": "people", "moji": "👹", + "description": "japanese ogre", "unicodeVersion": "6.0", "digest": "434ceedd102e7dcbc07e086811673dd63659ddf8c3ec4d029a3d759a0abfcbdb" }, "jeans": { "category": "people", "moji": "👖", + "description": "jeans", "unicodeVersion": "6.0", "digest": "f986ad32e419cca81c995f8371f0189d1490172a97ebbeac60054a1af08949c5" }, "joy": { "category": "people", "moji": "😂", + "description": "face with tears of joy", "unicodeVersion": "6.0", "digest": "75d7a05043523d290c46d3b313b19ed3c95271f1110bcf234cf13d4273625b08" }, "joy_cat": { "category": "people", "moji": "😹", + "description": "cat face with tears of joy", "unicodeVersion": "6.0", "digest": "a65c999604147e5e20170fcb14f80a1ff0a633f991492e1f790b2ad4caec7b7e" }, "joystick": { "category": "objects", "moji": "🕹", + "description": "joystick", "unicodeVersion": "7.0", "digest": "671ee588f397a96f27056a67e6a06d6e8d22c2109ec57b2859badb5fec9cf8dd" }, "juggling": { "category": "activity", "moji": "🤹", + "description": "juggling", "unicodeVersion": "9.0", "digest": "1f5dafa78de8b37f3df88fdf3084d2380666bd74ab2f449754d8724f6f8dbfa5" }, "juggling_tone1": { "category": "activity", "moji": "🤹🏻", + "description": "juggling tone 1", "unicodeVersion": "9.0", "digest": "b0b4d020148c896be69c28b08e3c486f6db270d138c7ccf4be362b29eb99878d" }, "juggling_tone2": { "category": "activity", "moji": "🤹🏼", + "description": "juggling tone 2", "unicodeVersion": "9.0", "digest": "cfe0c1649b2fdca03673e0e64f3a7d06d4bd49b8954c769aeb7eb88b70ec99f4" }, "juggling_tone3": { "category": "activity", "moji": "🤹🏽", + "description": "juggling tone 3", "unicodeVersion": "9.0", "digest": "7f87022722008bb265abe245e8157dc7a61944f5da62b3cf86f26ee1b3bdef63" }, "juggling_tone4": { "category": "activity", "moji": "🤹🏾", + "description": "juggling tone 4", "unicodeVersion": "9.0", "digest": "1f00da8c05582c95501cc6c3fe5ce0f9bfbc16789dcee59844a8fe7831198583" }, "juggling_tone5": { "category": "activity", "moji": "🤹🏿", + "description": "juggling tone 5", "unicodeVersion": "9.0", "digest": "a195bf734788eb7961c00dbc05255a49da8b9d5042fada29b26cc20393d3ce52" }, "kaaba": { "category": "travel", "moji": "🕋", + "description": "kaaba", "unicodeVersion": "8.0", "digest": "a4618782f9583f077bd383965f1c91b9985a949bb7b6cec7af22914e7f5e9ab6" }, "key": { "category": "objects", "moji": "🔑", + "description": "key", "unicodeVersion": "6.0", "digest": "66719fa77a50a0827c8d47237e2704c03e38186e6fef80627a765473b2294c2e" }, "key2": { "category": "objects", "moji": "🗝", + "description": "old key", "unicodeVersion": "7.0", "digest": "f57240a014a9da5da3d4d98c17d0a55e0ff2e5f2d22731d2fc867105cff54c6e" }, "keyboard": { "category": "objects", "moji": "⌨", + "description": "keyboard", "unicodeVersion": "1.1", "digest": "34da8ff62ca964142f9281b80123dbba74deaac8d77fa61758c30cfb36c31386" }, "kimono": { "category": "people", "moji": "👘", + "description": "kimono", "unicodeVersion": "6.0", "digest": "637182590e256c8fb74ce4c0565f5180c07f06e3bdebf30138ed3259b209c27f" }, "kiss": { "category": "people", "moji": "💋", + "description": "kiss mark", "unicodeVersion": "6.0", "digest": "62f9b9ffcb01558cd5bb829344a1d1d399511663ff5235405c1f786c9416a94d" }, "kiss_mm": { "category": "people", "moji": "👨❤️💋👨", + "description": "kiss (man,man)", "unicodeVersion": "6.0", "digest": "6b0ae32ecb7ec0f0f43dc7a1350711185cce114c52752395f364ddbfb4f1fff4" }, "kiss_ww": { "category": "people", "moji": "👩❤️💋👩", + "description": "kiss (woman,woman)", "unicodeVersion": "6.0", "digest": "6de420cf752e706b1b7e9522b1b9be62eda069cb028c8fd587caf39f6a142e6a" }, "kissing": { "category": "people", "moji": "😗", + "description": "kissing face", "unicodeVersion": "6.1", "digest": "b4a505f9e3d7fbd0ac60111f0e678cf425a5fd1abc65a3e9db59ae4abcfb8e85" }, "kissing_cat": { "category": "people", "moji": "😽", + "description": "kissing cat face with closed eyes", "unicodeVersion": "6.0", "digest": "a00431bf10601db4998e78433279167e52cbd36aed885399482529d5cdab8636" }, "kissing_closed_eyes": { "category": "people", "moji": "😚", + "description": "kissing face with closed eyes", "unicodeVersion": "6.0", "digest": "ae474db7daf80fe0b82ae1f2a11672cfcd9f9126e100f6e6d4b8a0d135dce39d" }, "kissing_heart": { "category": "people", "moji": "😘", + "description": "face throwing a kiss", "unicodeVersion": "6.0", "digest": "bce372573bd3b347b555c1cd22087e03e650df73c8e0284ab668bf6633251632" }, "kissing_smiling_eyes": { "category": "people", "moji": "😙", + "description": "kissing face with smiling eyes", "unicodeVersion": "6.1", "digest": "f0f8636cb1a02b93cc72ce1b194b890fca823d91e35926b889be3ecfae79207f" }, "kiwi": { "category": "food", "moji": "🥝", + "description": "kiwifruit", "unicodeVersion": "9.0", "digest": "70a3a05f333d9455d2da12eed970bc3baae416286848fed8e5dd31b5be0819be" }, "knife": { "category": "objects", "moji": "🔪", + "description": "hocho", "unicodeVersion": "6.0", "digest": "e6189e4843c6e80875b4952fcddb0c858f7c6039b9214bbec6a261a1358425df" }, "koala": { "category": "nature", "moji": "🐨", + "description": "koala", "unicodeVersion": "6.0", "digest": "c58f7e0abae42c2218a85efed0e04151df67187815bebca7f3db6f435e0dab4d" }, "koko": { "category": "symbols", "moji": "🈁", + "description": "squared katakana koko", "unicodeVersion": "6.0", "digest": "5f45eb49bbf298e1fadedfe6cccc297850fcaaa4535e4cc911d48d979af55807" }, "label": { "category": "objects", "moji": "🏷", + "description": "label", "unicodeVersion": "7.0", "digest": "9550ed50cedbc56eb1bd22a8a0809d837048a33d6e2e6e7d65c50d95fa05a85d" }, "large_blue_circle": { "category": "symbols", "moji": "🔵", + "description": "large blue circle", "unicodeVersion": "6.0", "digest": "0df3fb3b09a6269459a3d9a1fe78db572190a948680844cfe758f53b6a482ff4" }, "large_blue_diamond": { "category": "symbols", "moji": "🔷", + "description": "large blue diamond", "unicodeVersion": "6.0", "digest": "7f646b4e9de2788ed09e45f72cb512c269dda4989029b39bf9a2556659321651" }, "large_orange_diamond": { "category": "symbols", "moji": "🔶", + "description": "large orange diamond", "unicodeVersion": "6.0", "digest": "80ae005ef9d79190c777f00de0993f8b3cb783f7051d76e971640c8c0827c338" }, "last_quarter_moon": { "category": "nature", "moji": "🌗", + "description": "last quarter moon symbol", "unicodeVersion": "6.0", "digest": "3d1f276607c685d50f4b70d00a57750a57ad9ad84256dafd2dc8eef8c72300c3" }, "last_quarter_moon_with_face": { "category": "nature", "moji": "🌜", + "description": "last quarter moon with face", "unicodeVersion": "6.0", "digest": "d516825ba52dc67f5a01433fb9df2aa77742d38efde4225983ebc4882cbdfe5d" }, "laughing": { "category": "people", "moji": "😆", + "description": "smiling face with open mouth and tightly-closed ey", "unicodeVersion": "6.0", "digest": "e9ea994b39650740c4961f070ed492d86b3acf6e6a830a6dadaa3a6872e81b81" }, "leaves": { "category": "nature", "moji": "🍃", + "description": "leaf fluttering in wind", "unicodeVersion": "6.0", "digest": "56a7a0e767a6f214d340d1b5989efd99fec52c6aa306ec5c3328e32234a1631b" }, "ledger": { "category": "objects", "moji": "📒", + "description": "ledger", "unicodeVersion": "6.0", "digest": "e58cb714353e96a2891a5d97910ff79660e637af909b81c49c919d3735db55b4" }, "left_facing_fist": { "category": "people", "moji": "🤛", + "description": "left-facing fist", "unicodeVersion": "9.0", "digest": "7861be485beefae0de341df2f21576666e22f63511a033e785752f30c07291da" }, "left_facing_fist_tone1": { "category": "people", "moji": "🤛🏻", + "description": "left facing fist tone 1", "unicodeVersion": "9.0", "digest": "2e4c4dd96b0e4b46fe0f9ce5666344d266d0f17a8544cbae73d96638d1955296" }, "left_facing_fist_tone2": { "category": "people", "moji": "🤛🏼", + "description": "left facing fist tone 2", "unicodeVersion": "9.0", "digest": "b96a63a801175ce98a75f0edad7b5574251a3fbbd894d8ab3f21aeeda366cc13" }, "left_facing_fist_tone3": { "category": "people", "moji": "🤛🏽", + "description": "left facing fist tone 3", "unicodeVersion": "9.0", "digest": "99df84635513c2ebfef24df1bd3705233e02149eef788c7b82ca0548df6f6ea5" }, "left_facing_fist_tone4": { "category": "people", "moji": "🤛🏾", + "description": "left facing fist tone 4", "unicodeVersion": "9.0", "digest": "68954842ca725aec0aa39bce4aa81aad17ac30f5f298561dfa411feb07414cd3" }, "left_facing_fist_tone5": { "category": "people", "moji": "🤛🏿", + "description": "left facing fist tone 5", "unicodeVersion": "9.0", "digest": "a419b33fae82612dc860ff48950c0547a1642d4f0c94b6547324440837d3bb21" }, "left_luggage": { "category": "symbols", "moji": "🛅", + "description": "left luggage", "unicodeVersion": "6.0", "digest": "6625077767a51163ea20cbc299f3c13fd5ccf1b5ce365ee702ef1fef6be3dadf" }, "left_right_arrow": { "category": "symbols", "moji": "↔", + "description": "left right arrow", "unicodeVersion": "1.1", "digest": "560fcf1b794eb0d5269c73b3f8da57540cbb8a6f1a9af7a9d10b202252247e34" }, "leftwards_arrow_with_hook": { "category": "symbols", "moji": "↩", + "description": "leftwards arrow with hook", "unicodeVersion": "1.1", "digest": "504714c5559b1bd35aa469be83069a923d1a25f364cac08c10df0195749e7b26" }, "lemon": { "category": "food", "moji": "🍋", + "description": "lemon", "unicodeVersion": "6.0", "digest": "ccca25bb6ac47770dba3aaf75144128f9a73299061969b25a35ad1733dcde5fe" }, "leo": { "category": "symbols", "moji": "♌", + "description": "leo", "unicodeVersion": "1.1", "digest": "f2ed930e279699962f189e0cac519cc29d339b3e82debfdc90c5b0935a7543bb" }, "leopard": { "category": "nature", "moji": "🐆", + "description": "leopard", "unicodeVersion": "6.0", "digest": "d4a8964b6f2cdf6ddf074d0f1f2f65783a1a43eb4af426905fad0e60899939c7" }, "level_slider": { "category": "objects", "moji": "🎚", + "description": "level slider", "unicodeVersion": "7.0", "digest": "48842324f54d971ebf548a89a82ac7f29e235702081c91b477b1a92d427290e7" }, "levitate": { "category": "activity", "moji": "🕴", + "description": "man in business suit levitating", "unicodeVersion": "7.0", "digest": "453c24bf2544ed3ef3c710a7fabbd5fdace4dc65cddd377274d30d921523b50b" }, "libra": { "category": "symbols", "moji": "♎", + "description": "libra", "unicodeVersion": "1.1", "digest": "e330ba05bb449db074bc23d1514246ca5e249110f44ddb5804e5510eef6deac1" }, "lifter": { "category": "activity", "moji": "🏋", + "description": "weight lifter", "unicodeVersion": "7.0", "digest": "d6c94a32eb863d14a2a01add8ab95040f42a55d9e3f90641a0fe143d58127558" }, "lifter_tone1": { "category": "activity", "moji": "🏋🏻", + "description": "weight lifter tone 1", "unicodeVersion": "8.0", "digest": "870acf2f554fce360b58d3e98b4c0558d7ec7775587776c0f9d40c6fb1bdacf9" }, "lifter_tone2": { "category": "activity", "moji": "🏋🏼", + "description": "weight lifter tone 2", "unicodeVersion": "8.0", "digest": "1a7ece8512e42241cdd95c85ccc509bc0ff9c7c6ffaff2be343c77f417a27576" }, "lifter_tone3": { "category": "activity", "moji": "🏋🏽", + "description": "weight lifter tone 3", "unicodeVersion": "8.0", "digest": "4bc633ee82a0fb59feba379fb6901a489e4ac849d758f9c8e7a1a0a26eaa380c" }, "lifter_tone4": { "category": "activity", "moji": "🏋🏾", + "description": "weight lifter tone 4", "unicodeVersion": "8.0", "digest": "d086fe5577b5ba80676f2224d886f8ebe4588314f429f12a34c52c971ed71b5c" }, "lifter_tone5": { "category": "activity", "moji": "🏋🏿", + "description": "weight lifter tone 5", "unicodeVersion": "8.0", "digest": "79b0edf6ce1fd024dd7f458e322ad8588af0b789a04cc1cf38380dc8b9c76f55" }, "light_rail": { "category": "travel", "moji": "🚈", + "description": "light rail", "unicodeVersion": "6.0", "digest": "2f30b23a738371690b2f00d96ddb5ceb90a1442b5478754626a3dfa263ed2fc1" }, "link": { "category": "objects", "moji": "🔗", + "description": "link symbol", "unicodeVersion": "6.0", "digest": "7bf567aabd1fc38b3d70422f9db3a13b50950cf6207e70962c9938827c196ccb" }, "lion_face": { "category": "nature", "moji": "🦁", + "description": "lion face", "unicodeVersion": "8.0", "digest": "dd24f2668e973ec973e97dc111f59a2cc14e9b608387401191dd53368d28d4fa" }, "lips": { "category": "people", "moji": "👄", + "description": "mouth", "unicodeVersion": "6.0", "digest": "8740d8086525c7a836d64625a6915cc1c59af69ba143456dbb59e0179276895e" }, "lipstick": { "category": "people", "moji": "💄", + "description": "lipstick", "unicodeVersion": "6.0", "digest": "751dcb22706a796033b13a2ccb94304236ec13207ad4d011e02d230ae33ab5c1" }, "lizard": { "category": "nature", "moji": "🦎", + "description": "lizard", "unicodeVersion": "9.0", "digest": "fb9191f9eab58b8403d4c4626ccbb14ba05c1f6944011751a8edcc4dd03c66e6" }, "lock": { "category": "objects", "moji": "🔒", + "description": "lock", "unicodeVersion": "6.0", "digest": "043b4fc0b8c79d47a07d91308e628e1ac262aea6c1ec05e6b84bf7bcdf89dc83" }, "lock_with_ink_pen": { "category": "objects", "moji": "🔏", + "description": "lock with ink pen", "unicodeVersion": "6.0", "digest": "7b5e959b26cf7296c7b230fc2be9feb9e38391c5001951a019d16b169a71aba9" }, "lollipop": { "category": "food", "moji": "🍭", + "description": "lollipop", "unicodeVersion": "6.0", "digest": "17b6a0df47ec758a2f9c087b46a6902cee344d39407ef4c321e408505cbb72ca" }, "loop": { "category": "symbols", "moji": "➿", + "description": "double curly loop", "unicodeVersion": "6.0", "digest": "9f20ecc34b3c871789ba7d0712aa31e7a74b6c1558ac8bea385bc40590056726" }, "loud_sound": { "category": "symbols", "moji": "🔊", + "description": "speaker with three sound waves", "unicodeVersion": "6.0", "digest": "64b12db9ddd8adf74a9fc2bd83c7979ea865113347f7ce8666e9ccf5019e715f" }, "loudspeaker": { "category": "symbols", "moji": "📢", + "description": "public address loudspeaker", "unicodeVersion": "6.0", "digest": "1e1f35d16dd2898ebaa6f2b2868203df6e09c8a70df069c92d6d1b5cb2ac0976" }, "love_hotel": { "category": "travel", "moji": "🏩", + "description": "love hotel", "unicodeVersion": "6.0", "digest": "ff8966a50fd47a216855488eb09a367d231fea21f49e7e5325191d32fb494473" }, "love_letter": { "category": "objects", "moji": "💌", + "description": "love letter", "unicodeVersion": "6.0", "digest": "037261c8ca4d72f7205e51664591696da2ae7ceb19f1c1c9f6123da5a5979d29" }, "low_brightness": { "category": "symbols", "moji": "🔅", + "description": "low brightness symbol", "unicodeVersion": "6.0", "digest": "a065d00a416e297c168b0a675cafcf492fedf94865cb21801a1be5a3914593d4" }, "lying_face": { "category": "people", "moji": "🤥", + "description": "lying face", "unicodeVersion": "9.0", "digest": "ce836170165e1b70938273f289c02c2106873cd9ab5472dbcd487c2f9f53f13d" }, "m": { "category": "symbols", "moji": "Ⓜ", + "description": "circled latin capital letter m", "unicodeVersion": "1.1", "digest": "54588ac2b7fcd53a96f17124e9de69b617613fcd5af9ad2930a094cb795bb9f4" }, "mag": { "category": "objects", "moji": "🔍", + "description": "left-pointing magnifying glass", "unicodeVersion": "6.0", "digest": "a6e31a2efa7d9427aaa30b45d9f4181ee55c44be08aea2df165a86e0e6d9eaa1" }, "mag_right": { "category": "objects", "moji": "🔎", + "description": "right-pointing magnifying glass", "unicodeVersion": "6.0", "digest": "c7d8ceeb05db261e5eaab31dc4da432d0d5592a2ed71e526c5a542daa230bbaf" }, "mahjong": { "category": "symbols", "moji": "🀄", + "description": "mahjong tile red dragon", "unicodeVersion": "5.1", "digest": "755d69f988434ce1c17531a8b7ac92ead6f5607c2635a22f10e0ad70f09fc3e6" }, "mailbox": { "category": "objects", "moji": "📫", + "description": "closed mailbox with raised flag", "unicodeVersion": "6.0", "digest": "2069091be90a530a43ef29d5ec7688c351bf4d5b08d63a0d20d72b67d639ec62" }, "mailbox_closed": { "category": "objects", "moji": "📪", + "description": "closed mailbox with lowered flag", "unicodeVersion": "6.0", "digest": "d88d65bfebb8216535fd055c69f319564b2cf0b0901820f8312f581864557ed4" }, "mailbox_with_mail": { "category": "objects", "moji": "📬", + "description": "open mailbox with raised flag", "unicodeVersion": "6.0", "digest": "69e966b4659128991a70c6a2dd4d647551bedb91bdf5ce688958686bbec56381" }, "mailbox_with_no_mail": { "category": "objects", "moji": "📭", + "description": "open mailbox with lowered flag", "unicodeVersion": "6.0", "digest": "9e92d8ee88f660ce56da61077c80ec26c5d8f54ebd2306c4cfa16f6c1b981f83" }, "man": { "category": "people", "moji": "👨", + "description": "man", "unicodeVersion": "6.0", "digest": "42b882d2c6aa095f1afcf901203838d95c1908bdc725519779186b9c33c728d7" }, "man_dancing": { "category": "people", "moji": "🕺", + "description": "man dancing", "unicodeVersion": "9.0", "digest": "9f632ee0c886d5f03c61e5f3a27668262c0cc2693b857a91c23c1e5ea3785b9e" }, "man_dancing_tone1": { "category": "activity", "moji": "🕺🏻", + "description": "man dancing tone 1", "unicodeVersion": "9.0", "digest": "6c56a16cb105bcdd97472645b3a351cebdbb1132cbfd18b0118f289db5fbe741" }, "man_dancing_tone2": { "category": "activity", "moji": "🕺🏼", + "description": "man dancing tone 2", "unicodeVersion": "9.0", "digest": "ed7e78c14d205a03fdd5581e5213add69a55e13b4cbaf76a6d5a0d6c80f53327" }, "man_dancing_tone3": { "category": "activity", "moji": "🕺🏽", + "description": "man dancing tone 3", "unicodeVersion": "9.0", "digest": "13b45403e11800163406206eedeb8b579cc83eca2f60246be97e099164387bc8" }, "man_dancing_tone4": { "category": "activity", "moji": "🕺🏾", + "description": "man dancing tone 4", "unicodeVersion": "9.0", "digest": "f6feb1b0b83565fadcdd1a8737d3daa08893e919547d2a06de899160162d9c4a" }, "man_dancing_tone5": { "category": "activity", "moji": "🕺🏿", + "description": "man dancing tone 5", "unicodeVersion": "9.0", "digest": "fe20a9ed9ba991653b4d0683de347ed7c226a5d75610307584a2ddd6fcd1e3f2" }, "man_in_tuxedo": { "category": "people", "moji": "🤵", + "description": "man in tuxedo", "unicodeVersion": "9.0", "digest": "4d451a971dfefedc4830ba78e19b123f250e09ae65baddccdc56c0f8aa3a9b50" }, "man_in_tuxedo_tone1": { "category": "people", "moji": "🤵🏻", + "description": "man in tuxedo tone 1", "unicodeVersion": "9.0", "digest": "2814833334fb211ae2ecb1fb5964e9752282d0fb4d7f3477de5dd2a4f812a793" }, "man_in_tuxedo_tone2": { "category": "people", "moji": "🤵🏼", + "description": "man in tuxedo tone 2", "unicodeVersion": "9.0", "digest": "cd1bab9ee0e2335d3cd99d51216cccdc4fc3c2cf20129b8b7e11a51a77258f68" }, "man_in_tuxedo_tone3": { "category": "people", "moji": "🤵🏽", + "description": "man in tuxedo tone 3", "unicodeVersion": "9.0", "digest": "f387775f925fe60b9f3e7cad63a55d4d196ddd41658029a70440d14c17cb99f9" }, "man_in_tuxedo_tone4": { "category": "people", "moji": "🤵🏾", + "description": "man in tuxedo tone 4", "unicodeVersion": "9.0", "digest": "08debd7a573d1201aee8a2f281ef7cb638d4a2a096222150391f36963f07c622" }, "man_in_tuxedo_tone5": { "category": "people", "moji": "🤵🏿", + "description": "man in tuxedo tone 5", "unicodeVersion": "9.0", "digest": "e3b10e0619f0911cf9b665a265f4ef829b8f6ba6e9c3a021d0539a27e315f8fe" }, "man_tone1": { "category": "people", "moji": "👨🏻", + "description": "man tone 1", "unicodeVersion": "8.0", "digest": "7053e265fa7d2594de54a6c5d06c21795b9a7dfb36a1c5594ca43c4c6cc56504" }, "man_tone2": { "category": "people", "moji": "👨🏼", + "description": "man tone 2", "unicodeVersion": "8.0", "digest": "7ebc64de40d3ac60fb761be5cf94f53fa10b4f03fb66add46c90f5d98eaf71eb" }, "man_tone3": { "category": "people", "moji": "👨🏽", + "description": "man tone 3", "unicodeVersion": "8.0", "digest": "77ceef4d3740ed4751acb83dd45b6b754cf625c522c6757309cd4d61202d7149" }, "man_tone4": { "category": "people", "moji": "👨🏾", + "description": "man tone 4", "unicodeVersion": "8.0", "digest": "41e6037c393f61cca61b9a81b27ed14a95d75fe380e3a00153c33a371a836ffd" }, "man_tone5": { "category": "people", "moji": "👨🏿", + "description": "man tone 5", "unicodeVersion": "8.0", "digest": "a8cebfd39a5b9c79af7cc37f205e1135376056fee287af967c9f55d415572d99" }, "man_with_gua_pi_mao": { "category": "people", "moji": "👲", + "description": "man with gua pi mao", "unicodeVersion": "6.0", "digest": "3dae285e900c69986a48db0fa89d4f371a49f38608059cdae52be098030c5ac4" }, "man_with_gua_pi_mao_tone1": { "category": "people", "moji": "👲🏻", + "description": "man with gua pi mao tone 1", "unicodeVersion": "8.0", "digest": "35404d8e266920c78edd9e7143fb052b42f65242a5698494c4f4365e9183cc67" }, "man_with_gua_pi_mao_tone2": { "category": "people", "moji": "👲🏼", + "description": "man with gua pi mao tone 2", "unicodeVersion": "8.0", "digest": "82d4f968665a93c7543372c8a1eeb0f25d0ea6842d5e518bd91c226c6c3ab8c2" }, "man_with_gua_pi_mao_tone3": { "category": "people", "moji": "👲🏽", + "description": "man with gua pi mao tone 3", "unicodeVersion": "8.0", "digest": "f44159f0c672b9b833449382896180e799abf574f5b3c6cd9541caa992fa18ce" }, "man_with_gua_pi_mao_tone4": { "category": "people", "moji": "👲🏾", + "description": "man with gua pi mao tone 4", "unicodeVersion": "8.0", "digest": "c79060188f9461ca34eaa225b7682d8c410883609509fb731c992db69bfeeb50" }, "man_with_gua_pi_mao_tone5": { "category": "people", "moji": "👲🏿", + "description": "man with gua pi mao tone 5", "unicodeVersion": "8.0", "digest": "de9e4acdb10f7abddeeabc0b48d91139fc8b544a601c530db811f099991b0d38" }, "man_with_turban": { "category": "people", "moji": "👳", + "description": "man with turban", "unicodeVersion": "6.0", "digest": "db72c944e93983f38d00e3e936ebb5b243c6069f1f1236d46f6a9f1beb8d6634" }, "man_with_turban_tone1": { "category": "people", "moji": "👳🏻", + "description": "man with turban tone 1", "unicodeVersion": "8.0", "digest": "b6d7489c4cd151af09fff48b62c54c336303e14866e6ef38f94cd834b085d09e" }, "man_with_turban_tone2": { "category": "people", "moji": "👳🏼", + "description": "man with turban tone 2", "unicodeVersion": "8.0", "digest": "7854ef973c21847f452d7e78e5c460ea300e12b539ce92c69dabe8f1bf3a4382" }, "man_with_turban_tone3": { "category": "people", "moji": "👳🏽", + "description": "man with turban tone 3", "unicodeVersion": "8.0", "digest": "1dbd9bd78f5263cbadee7d0d5754c14cfbc914f7329e25fbd97d9f5b8ce0737e" }, "man_with_turban_tone4": { "category": "people", "moji": "👳🏾", + "description": "man with turban tone 4", "unicodeVersion": "8.0", "digest": "4f4804da4a7c98ad4f9db3ae3eaf674c8977c638e73414e33ef1f65098e413a3" }, "man_with_turban_tone5": { "category": "people", "moji": "👳🏿", + "description": "man with turban tone 5", "unicodeVersion": "8.0", "digest": "240282aa346ef9b1d0d475ea93a02597697f0f56f086305879b532b0b933210a" }, "mans_shoe": { "category": "people", "moji": "👞", + "description": "mans shoe", "unicodeVersion": "6.0", "digest": "f53fe74abd9906cd3e2dd7e7bddbe1feb9f8f7be28b807fabe452f1f60ca1b84" }, "map": { "category": "objects", "moji": "🗺", + "description": "world map", "unicodeVersion": "7.0", "digest": "84f496a062b5c3ae1e8013506175a69036038c8130891bcf780a69ce7fcbe4de" }, "maple_leaf": { "category": "nature", "moji": "🍁", + "description": "maple leaf", "unicodeVersion": "6.0", "digest": "72629a205e33f89337815ad7e51bb5c73947d1a9f98afe5072bdf4846827ae72" }, "martial_arts_uniform": { "category": "activity", "moji": "🥋", + "description": "martial arts uniform", "unicodeVersion": "9.0", "digest": "a1ae797b31081425b388ab31efc635d8eb73a40980fd0fae4708aa5313e2a964" }, "mask": { "category": "people", "moji": "😷", + "description": "face with medical mask", "unicodeVersion": "6.0", "digest": "1b58af9ae599308aabf41bbd38f599fa896bd9fe5df7a40be9f2dc7e0e230600" }, "massage": { "category": "people", "moji": "💆", + "description": "face massage", "unicodeVersion": "6.0", "digest": "6ee48b4d8cec0bf31e11d7803ad9fc1f909457c8c00cb320b5671395af3c170c" }, "massage_tone1": { "category": "people", "moji": "💆🏻", + "description": "face massage tone 1", "unicodeVersion": "8.0", "digest": "9da162c2f39628156b87db986a6ada59372a9e9a6b3f0488d21c9e65ec3309bb" }, "massage_tone2": { "category": "people", "moji": "💆🏼", + "description": "face massage tone 2", "unicodeVersion": "8.0", "digest": "ac259188549b5b429b8c4929e1da2314859e8857ee49720551467aedfcc96567" }, "massage_tone3": { "category": "people", "moji": "💆🏽", + "description": "face massage tone 3", "unicodeVersion": "8.0", "digest": "cfd9c105b6debc10448f172afcb20d4192899f7ae5aa8af54c834153a5466364" }, "massage_tone4": { "category": "people", "moji": "💆🏾", + "description": "face massage tone 4", "unicodeVersion": "8.0", "digest": "38ab715c621c58454f3cb09153a96380118cf082568554b6edc5f83fb62e9297" }, "massage_tone5": { "category": "people", "moji": "💆🏿", + "description": "face massage tone 5", "unicodeVersion": "8.0", "digest": "32480457734121b0c83e9be6d693ae379c95535f43f963c0c2f0f20434ee12c6" }, "meat_on_bone": { "category": "food", "moji": "🍖", + "description": "meat on bone", "unicodeVersion": "6.0", "digest": "d71a8e0b118d5e6ca60690793ce9649afb78e707fcbd7be890a75564c94434fd" }, "medal": { "category": "activity", "moji": "🏅", + "description": "sports medal", "unicodeVersion": "7.0", "digest": "9600cbe57e08da090c60629bcafd2821c87322e738c2454f8e883ceb756e7391" }, "mega": { "category": "symbols", "moji": "📣", + "description": "cheering megaphone", "unicodeVersion": "6.0", "digest": "4b1def6b5b051c5045514063f0ac006222ad81fbfe56d840e14bb950713e331b" }, "melon": { "category": "food", "moji": "🍈", + "description": "melon", "unicodeVersion": "6.0", "digest": "0cdd663e6f2129808856cdf0746e6571b62aac641f224adb553baf3bb63ba3bd" }, "menorah": { "category": "symbols", "moji": "🕎", + "description": "menorah with nine branches", "unicodeVersion": "8.0", "digest": "49fca8c3bc00ea69653ee2f8d4e21e561856ba39716c13e9d107db3e805a2997" }, "mens": { "category": "symbols", "moji": "🚹", + "description": "mens symbol", "unicodeVersion": "6.0", "digest": "7d92292586ee12a5d1a557c37da4d14708dc3ce701cf32d3280dcc83d91e5df8" }, "metal": { "category": "people", "moji": "🤘", + "description": "sign of the horns", "unicodeVersion": "8.0", "digest": "ffb750caf187f5d821c990108e2699ac3e216492bcff6ee543f4a7aa55b9fd29" }, "metal_tone1": { "category": "people", "moji": "🤘🏻", + "description": "sign of the horns tone 1", "unicodeVersion": "8.0", "digest": "5505f0b0340f9ba572db8897e40adf598cfa784686ad5ee360a7351bf44ddc1d" }, "metal_tone2": { "category": "people", "moji": "🤘🏼", + "description": "sign of the horns tone 2", "unicodeVersion": "8.0", "digest": "8f9eee3ad5fc7eeeb30118d16d27467b16fd87297e0ecf02656db77e701f5aeb" }, "metal_tone3": { "category": "people", "moji": "🤘🏽", + "description": "sign of the horns tone 3", "unicodeVersion": "8.0", "digest": "8270a7ecf5eb11431a07ef04cc476c2651ac8aacb0d4768e5cb69355f8a5e84e" }, "metal_tone4": { "category": "people", "moji": "🤘🏾", + "description": "sign of the horns tone 4", "unicodeVersion": "8.0", "digest": "f24f7b137dd6c7899dc0a8794204bbde7ad43ec1e63b419c90dd70a8b77871e8" }, "metal_tone5": { "category": "people", "moji": "🤘🏿", + "description": "sign of the horns tone 5", "unicodeVersion": "8.0", "digest": "07b0726a632653b980df775f460cd3fe1ea8d4a7b0b46fe29e089b66579482d2" }, "metro": { "category": "travel", "moji": "🚇", + "description": "metro", "unicodeVersion": "6.0", "digest": "b380247b61b5e2ca1b9b70fabff65907b2c3a5191a14b169ae094af94659b9b1" }, "microphone": { "category": "activity", "moji": "🎤", + "description": "microphone", "unicodeVersion": "6.0", "digest": "9ef4fc2e40d5391c4bb2d30f34f59662cff7cbb1b04341c9dac210d0e21b44ae" }, "microphone2": { "category": "objects", "moji": "🎙", + "description": "studio microphone", "unicodeVersion": "7.0", "digest": "8a30464d51f7f101335778444c43270ac0679900f49463e6556682d9db1cb4dc" }, "microscope": { "category": "objects", "moji": "🔬", + "description": "microscope", "unicodeVersion": "6.0", "digest": "4ca4322c6ba99b8c15acdb8b605f84f87398769e504b262b134c1f3868b2692f" }, "middle_finger": { "category": "people", "moji": "🖕", + "description": "reversed hand with middle finger extended", "unicodeVersion": "7.0", "digest": "0c3f1cc0ec7323f6d19508ad22fa90050845f7b5cc83f599ab2cacb89cf5dd0e" }, "middle_finger_tone1": { "category": "people", "moji": "🖕🏻", + "description": "reversed hand with middle finger extended tone 1", "unicodeVersion": "8.0", "digest": "4ebecf1058a3059aaa826eaad39c1a791120f115f65dde6d6ae32fc5561f60f7" }, "middle_finger_tone2": { "category": "people", "moji": "🖕🏼", + "description": "reversed hand with middle finger extended tone 2", "unicodeVersion": "8.0", "digest": "85ff506a08c38663c2dfa2e3a90584c02a36aa3dda33af47cdb49834bf9baf83" }, "middle_finger_tone3": { "category": "people", "moji": "🖕🏽", + "description": "reversed hand with middle finger extended tone 3", "unicodeVersion": "8.0", "digest": "cac697ff5207bf8a4e091912f3127f4e73c88ef69b5c6561d1d7b12ed60be8f1" }, "middle_finger_tone4": { "category": "people", "moji": "🖕🏾", + "description": "reversed hand with middle finger extended tone 4", "unicodeVersion": "8.0", "digest": "9324a5a4e3986b798ad8c61f31c18fb507ca7a4abfd6e9ae1408b80b185bf8c7" }, "middle_finger_tone5": { "category": "people", "moji": "🖕🏿", + "description": "reversed hand with middle finger extended tone 5", "unicodeVersion": "8.0", "digest": "078f917cd4d8be08a880724e9400449980d92740ccbee4a57f5046a9cf7f6575" }, "military_medal": { "category": "activity", "moji": "🎖", + "description": "military medal", "unicodeVersion": "7.0", "digest": "5da18351dc14b66cfc070148c83b7c8e67e6b1e3f515ae501133c38ee5c28d3d" }, "milk": { "category": "food", "moji": "🥛", + "description": "glass of milk", "unicodeVersion": "9.0", "digest": "38b28ea40399601fabc95bac5eaaf5a9e4e25548ec80325bd5069395ea884f85" }, "milky_way": { "category": "travel", "moji": "🌌", + "description": "milky way", "unicodeVersion": "6.0", "digest": "17405ff31d94b13a1fb0adcda204b8adb95ca340bc3980d9ad9f42ba1e366e7d" }, "minibus": { "category": "travel", "moji": "🚐", + "description": "minibus", "unicodeVersion": "6.0", "digest": "08ccb4b1bf397b7c9aed901e2b5dcdd6cb8ca5c5487ef26775bb3120f7b92524" }, "minidisc": { "category": "objects", "moji": "💽", + "description": "minidisc", "unicodeVersion": "6.0", "digest": "bebf82c0b91ef66321e7ae7a0abf322e59b2f7d8e6fbf9a94243210c00229c59" }, "mobile_phone_off": { "category": "symbols", "moji": "📴", + "description": "mobile phone off", "unicodeVersion": "6.0", "digest": "6f9d8d6a32fc998f5d8144a5ff7e2ad00de37ad464cd97285e7c72efb09a1feb" }, "money_mouth": { "category": "people", "moji": "🤑", + "description": "money-mouth face", "unicodeVersion": "8.0", "digest": "5a43973dadf48a89201b1816fea9972c5cfe501a26fe457b6f7eee0a6362018e" }, "money_with_wings": { "category": "objects", "moji": "💸", + "description": "money with wings", "unicodeVersion": "6.0", "digest": "15fcf0595021374ba091ca00efdb4167770da4d421eab930964108545f4edab9" }, "moneybag": { "category": "objects", "moji": "💰", + "description": "money bag", "unicodeVersion": "6.0", "digest": "02d708e2f603b0df6f6c169b5c49b3452e1c02e7d72e96f228b73d0b0a20bff4" }, "monkey": { "category": "nature", "moji": "🐒", + "description": "monkey", "unicodeVersion": "6.0", "digest": "3588a544d6d9e9995b45d60327a1a42002fa1faa4d48224b140facd249af1c67" }, "monkey_face": { "category": "nature", "moji": "🐵", + "description": "monkey face", "unicodeVersion": "6.0", "digest": "9e263ef5ca42bb76d1b1d1e3cbf020bcf05023a6e9f91301d30c9eb406363a2a" }, "monorail": { "category": "travel", "moji": "🚝", + "description": "monorail", "unicodeVersion": "6.0", "digest": "2c9f185babcb4001fcef2b8dfc4a32126729843084d0076c3e3ccdc845ab23ad" }, "mortar_board": { "category": "people", "moji": "🎓", + "description": "graduation cap", "unicodeVersion": "6.0", "digest": "d7fbe41d4b340d3564e484aec46a22c9613521414b2ba6eece2180db4d23e410" }, "mosque": { "category": "travel", "moji": "🕌", + "description": "mosque", "unicodeVersion": "8.0", "digest": "5f3d3de7feac953a70a318113531c2857d760a516c3d8d6f42d2a3b3b67ed196" }, "motor_scooter": { "category": "travel", "moji": "🛵", + "description": "motor scooter", "unicodeVersion": "9.0", "digest": "e2dc7c981744a71f46858bd0858ff91af704ac06425ed80377bc3b119e57c872" }, "motorboat": { "category": "travel", "moji": "🛥", + "description": "motorboat", "unicodeVersion": "7.0", "digest": "81c156643528c5a94a12d6d478e52a019f5a4e3eb58ee365cdd9d2361a7fdb01" }, "motorcycle": { "category": "travel", "moji": "🏍", + "description": "racing motorcycle", "unicodeVersion": "7.0", "digest": "354aa8157732184ad50eff9330f7a8915309dc9b7893cc308226adb429311a62" }, "motorway": { "category": "travel", "moji": "🛣", + "description": "motorway", "unicodeVersion": "7.0", "digest": "148c3c13c7c4565453d16e504e0d4b8d007e4f2cad1ab56b1b51fefe39162d17" }, "mount_fuji": { "category": "travel", "moji": "🗻", + "description": "mount fuji", "unicodeVersion": "6.0", "digest": "f8093b9dba62b22c6c88f137be88b2fd3971c560714db15ec053cf697a3820bc" }, "mountain": { "category": "travel", "moji": "⛰", + "description": "mountain", "unicodeVersion": "5.2", "digest": "07423804ad79da68f140948d29df193f5d5343b7b2c23758c086697c4d3a50da" }, "mountain_bicyclist": { "category": "activity", "moji": "🚵", + "description": "mountain bicyclist", "unicodeVersion": "6.0", "digest": "91084b6c887cb7e34f3d7ec30656ecb82c36cc987f53a6c83ccb4c6f7950f96a" }, "mountain_bicyclist_tone1": { "category": "activity", "moji": "🚵🏻", + "description": "mountain bicyclist tone 1", "unicodeVersion": "8.0", "digest": "5d57fcfad61bca26c3e8965eb57602a1993a3117ebdda0f24569af730310ab6e" }, "mountain_bicyclist_tone2": { "category": "activity", "moji": "🚵🏼", + "description": "mountain bicyclist tone 2", "unicodeVersion": "8.0", "digest": "c0da7fb85d99aa01a665f64063cd7e2d994f8a16d3f6fbf52df5d471e771a98a" }, "mountain_bicyclist_tone3": { "category": "activity", "moji": "🚵🏽", + "description": "mountain bicyclist tone 3", "unicodeVersion": "8.0", "digest": "b099e7ee84eae44ebc99023fa06bdf37ffa0d69767c7c0163a89f7ced2a26765" }, "mountain_bicyclist_tone4": { "category": "activity", "moji": "🚵🏾", + "description": "mountain bicyclist tone 4", "unicodeVersion": "8.0", "digest": "9d09f7b3899ea44e736f237a161ef8d5170dccfa162a872c59532ceaf65ee007" }, "mountain_bicyclist_tone5": { "category": "activity", "moji": "🚵🏿", + "description": "mountain bicyclist tone 5", "unicodeVersion": "8.0", "digest": "71e374981d955056748a60c6d1820b45e9688a156b55318b4ea54a3a67ca801c" }, "mountain_cableway": { "category": "travel", "moji": "🚠", + "description": "mountain cableway", "unicodeVersion": "6.0", "digest": "e261c3292758b1c0063c5a0d0c7f5c9803306d2265e08677027e1210506ced94" }, "mountain_railway": { "category": "travel", "moji": "🚞", + "description": "mountain railway", "unicodeVersion": "6.0", "digest": "b0987f8f391b3cbc7a56b9b8945ebfca240e01d12f8fd163877ebebe51d6b277" }, "mountain_snow": { "category": "travel", "moji": "🏔", + "description": "snow capped mountain", "unicodeVersion": "7.0", "digest": "49aac2b851aa6f2bd2ca641efa8060f93e89395357f49d211658d46f5a2b0189" }, "mouse": { "category": "nature", "moji": "🐭", + "description": "mouse face", "unicodeVersion": "6.0", "digest": "007dd108507b45224f7a1fad3c1de6ecc75f38d71fc142744611eb13555f5eff" }, "mouse2": { "category": "nature", "moji": "🐁", + "description": "mouse", "unicodeVersion": "6.0", "digest": "f3ed37b639b7c16aae49502bd423f9fdeabaf15bc6f0f74063954b189e176b5d" }, "mouse_three_button": { "category": "objects", "moji": "🖱", + "description": "three button mouse", "unicodeVersion": "7.0", "digest": "3724341ac5ad0d01027ef1575db64f1db7619f590ca6ada960d1f2c18dc7fc6a" }, "movie_camera": { "category": "objects", "moji": "🎥", + "description": "movie camera", "unicodeVersion": "6.0", "digest": "f7e285eda35b4431c07951e071643ddc34147cd76640e0d516fbfd11208346e9" }, "moyai": { "category": "objects", "moji": "🗿", + "description": "moyai", "unicodeVersion": "6.0", "digest": "2c1d0662c95928936e6b9ab5a40c6110ff1cea5339f2803c7b63aabc76115afb" }, "mrs_claus": { "category": "people", "moji": "🤶", + "description": "mother christmas", "unicodeVersion": "9.0", "digest": "1f72f586ca75bd7ebb4150cdcc8199a930c32fa4b81510cb8d200f1b3ddd4076" }, "mrs_claus_tone1": { "category": "people", "moji": "🤶🏻", + "description": "mother christmas tone 1", "unicodeVersion": "9.0", "digest": "244596919e0fed050203cf9e040899de323d7821235929f175852439927bd129" }, "mrs_claus_tone2": { "category": "people", "moji": "🤶🏼", + "description": "mother christmas tone 2", "unicodeVersion": "9.0", "digest": "8cde96e8521f3a90262a7f5f8a2989a9590d9a02cda2c37e92335dc05975c18d" }, "mrs_claus_tone3": { "category": "people", "moji": "🤶🏽", + "description": "mother christmas tone 3", "unicodeVersion": "9.0", "digest": "c39cd4346d4581799dd0e9a6447c91a954a75747bf2682c8e4d79c3b0fcf7405" }, "mrs_claus_tone4": { "category": "people", "moji": "🤶🏾", + "description": "mother christmas tone 4", "unicodeVersion": "9.0", "digest": "84c85cf54559ea2d78d196fee96149a249af4f959b78e223a0ec4fb72abdbcab" }, "mrs_claus_tone5": { "category": "people", "moji": "🤶🏿", + "description": "mother christmas tone 5", "unicodeVersion": "9.0", "digest": "ce26c0e0645713b17e7497d9f2d0484cc5477564dae99320cabf04d160d3b2ff" }, "muscle": { "category": "people", "moji": "💪", + "description": "flexed biceps", "unicodeVersion": "6.0", "digest": "e4ce52757b2b7982e2516e0e8bf2e2253617cc9f3e6178f1887c61c9039461ba" }, "muscle_tone1": { "category": "people", "moji": "💪🏻", + "description": "flexed biceps tone 1", "unicodeVersion": "8.0", "digest": "4a2fa226a05bb847b62cdd163eb6c2d514d3c2330a727991cf550c0d32b0e818" }, "muscle_tone2": { "category": "people", "moji": "💪🏼", + "description": "flexed biceps tone 2", "unicodeVersion": "8.0", "digest": "a8d5ecce335c782ca5f5e55763c06cfefa1c16c24cd6602237cf125d4ff95e47" }, "muscle_tone3": { "category": "people", "moji": "💪🏽", + "description": "flexed biceps tone 3", "unicodeVersion": "8.0", "digest": "070354b443faec3969663b770545fc4cf5ec75148557b2b9d6fc82ab22b43bd1" }, "muscle_tone4": { "category": "people", "moji": "💪🏾", + "description": "flexed biceps tone 4", "unicodeVersion": "8.0", "digest": "8eafcdb6a607aeafa673c257df0d2a1b20f00fc0868d811babcbe784490a0dd3" }, "muscle_tone5": { "category": "people", "moji": "💪🏿", + "description": "flexed biceps tone 5", "unicodeVersion": "8.0", "digest": "85a1e2b5c89907694240e9c5b9d876a741fa7ba38918c5718273e289cbc40efe" }, "mushroom": { "category": "nature", "moji": "🍄", + "description": "mushroom", "unicodeVersion": "6.0", "digest": "aaca8cf7c5cfa4487b5fef365a231f98be4bbf041197fc022161bcc8ce6f57c8" }, "musical_keyboard": { "category": "activity", "moji": "🎹", + "description": "musical keyboard", "unicodeVersion": "6.0", "digest": "fb0a726728900377d76d94aac9c94dce29107e8e3f1dcb0599d95bce7169b492" }, "musical_note": { "category": "symbols", "moji": "🎵", + "description": "musical note", "unicodeVersion": "6.0", "digest": "41288e79b4070bb980281d0e0d1c14d8b144b4aedb2eaadb9f2bebcb4ef892b4" }, "musical_score": { "category": "activity", "moji": "🎼", + "description": "musical score", "unicodeVersion": "6.0", "digest": "f0f91b9fa4a2bff7a5a1a11afa6f31cfe7e5fa8b0d6f3cce904b781a28ed0277" }, "mute": { "category": "symbols", "moji": "🔇", + "description": "speaker with cancellation stroke", "unicodeVersion": "6.0", "digest": "def277da49d744b55c7cdde269a15aa05315898f615e721ee7e9205d7b8030d6" }, "nail_care": { "category": "people", "moji": "💅", + "description": "nail polish", "unicodeVersion": "6.0", "digest": "48b33b1dbbd25b4f34ab2ca07bb99ddaaaa741990142c5623310f76b78c076f9" }, "nail_care_tone1": { "category": "people", "moji": "💅🏻", + "description": "nail polish tone 1", "unicodeVersion": "8.0", "digest": "a9ac92a34f407e7dd7c71377e6275e66657f7f42e4b911c540d1a66a02d92ac5" }, "nail_care_tone2": { "category": "people", "moji": "💅🏼", + "description": "nail polish tone 2", "unicodeVersion": "8.0", "digest": "f295ec85980aaa75818fad619c3d25042146ecbbf361db9e9bb96e7bc202bc73" }, "nail_care_tone3": { "category": "people", "moji": "💅🏽", + "description": "nail polish tone 3", "unicodeVersion": "8.0", "digest": "02ec373052a250977298bae85262177910126cc10de9480f1afa328ac2f65a95" }, "nail_care_tone4": { "category": "people", "moji": "💅🏾", + "description": "nail polish tone 4", "unicodeVersion": "8.0", "digest": "f3d95390ab59caedfda66122bbd0acf3aabedc142fc48352d68900766a7e6f5c" }, "nail_care_tone5": { "category": "people", "moji": "💅🏿", + "description": "nail polish tone 5", "unicodeVersion": "8.0", "digest": "009423c97f2aafd24fb8c7c485c58b30bbf9ae6797cc14b80d472b207327b518" }, "name_badge": { "category": "symbols", "moji": "📛", + "description": "name badge", "unicodeVersion": "6.0", "digest": "f9f6a4895ff0be8fb2ccc7ad195b94e9650f742f66ead999e90724cfb77af628" }, "nauseated_face": { "category": "people", "moji": "🤢", + "description": "nauseated face", "unicodeVersion": "9.0", "digest": "f8471cf4720948d8246ec9d30e29783e819f90e3cfe8b1ba628671a1aad1a91c" }, "necktie": { "category": "people", "moji": "👔", + "description": "necktie", "unicodeVersion": "6.0", "digest": "01bb18dc8bfe787daa9613b5d09988cd5a065449ef906099ce3cb308c8a7da68" }, "negative_squared_cross_mark": { "category": "symbols", "moji": "❎", + "description": "negative squared cross mark", "unicodeVersion": "6.0", "digest": "1cdaf4abc9adafa089c91c2e33a24e9e647aea0f857e767941a899a16ec53b74" }, "nerd": { "category": "people", "moji": "🤓", + "description": "nerd face", "unicodeVersion": "8.0", "digest": "9e5f3c93db25cf1d0f9d6e6bd2993161afec6c30573ba3fe85e13b8c84483d66" }, "neutral_face": { "category": "people", "moji": "😐", + "description": "neutral face", "unicodeVersion": "6.0", "digest": "7449430a60619956573e9dc80834045296f2b99853737b6c7794c785ff53d64e" }, "new": { "category": "symbols", "moji": "🆕", + "description": "squared new", "unicodeVersion": "6.0", "digest": "e20bc3e9f40726afd0cfb7268d02f1e1a07343364fd08b252d59f38de067bf06" }, "new_moon": { "category": "nature", "moji": "🌑", + "description": "new moon symbol", "unicodeVersion": "6.0", "digest": "dbfc5dcae34b45f15ff767e297cba3a12cb83f3b542db8cfc8dbd9669e0df46c" }, "new_moon_with_face": { "category": "nature", "moji": "🌚", + "description": "new moon with face", "unicodeVersion": "6.0", "digest": "c66d347d2222ac8d77d323a07699aff6b168328648db4f885b1ed0e2831fd59b" }, "newspaper": { "category": "objects", "moji": "📰", + "description": "newspaper", "unicodeVersion": "6.0", "digest": "c05e986d9cdac11afa30c6a21a72572ddf50fc64e87ae0c4e0ad57ffe70acc5c" }, "newspaper2": { "category": "objects", "moji": "🗞", + "description": "rolled-up newspaper", "unicodeVersion": "7.0", "digest": "63db7bcf51effc73e5124392740736383774a4bcfbc1156cf55599504760883d" }, "ng": { "category": "symbols", "moji": "🆖", + "description": "squared ng", "unicodeVersion": "6.0", "digest": "34d5a11c70f48ea719e602908534f446b192622e775d4160f0e1ec52c342a35c" }, "night_with_stars": { "category": "travel", "moji": "🌃", + "description": "night with stars", "unicodeVersion": "6.0", "digest": "39d9c079be80ee6ce1667531be528a2aa7f8bd46c7b6c2a6ee279d9a207c84a4" }, "nine": { "category": "symbols", "moji": "9️⃣", + "description": "keycap digit nine", "unicodeVersion": "3.0", "digest": "8bb40750eda8506ef877c9a3b8e2039d26f20eef345742f635740574a7e8daa6" }, "no_bell": { "category": "symbols", "moji": "🔕", + "description": "bell with cancellation stroke", "unicodeVersion": "6.0", "digest": "6542a9a5656c79c153f8c37f12d48f677c89b02ed0989ae37fa5e51ce6895422" }, "no_bicycles": { "category": "symbols", "moji": "🚳", + "description": "no bicycles", "unicodeVersion": "6.0", "digest": "af71c183545da2ff4c05609f9d572edb64b63ccba7c6a4b208d271558aa92b0a" }, "no_entry": { "category": "symbols", "moji": "⛔", + "description": "no entry", "unicodeVersion": "5.2", "digest": "dc0bac1ed9ab8e9af143f0fce5043fe68f7f46bd80856cdec95d20c3999b637d" }, "no_entry_sign": { "category": "symbols", "moji": "🚫", + "description": "no entry sign", "unicodeVersion": "6.0", "digest": "2c1fceef23b62effca68e0e087b8f020125d25b98d61492b1540055d1914fdc3" }, "no_good": { "category": "people", "moji": "🙅", + "description": "face with no good gesture", "unicodeVersion": "6.0", "digest": "6eb970b104389be5d18657d7c04be5149958c26855c52ea68574af852c5f85c4" }, "no_good_tone1": { "category": "people", "moji": "🙅🏻", + "description": "face with no good gesture tone 1", "unicodeVersion": "8.0", "digest": "c20a24a1e536240b4dcf90ecb530796de621d7ba1fb9e3fa0f849d048c509c03" }, "no_good_tone2": { "category": "people", "moji": "🙅🏼", + "description": "face with no good gesture tone 2", "unicodeVersion": "8.0", "digest": "f31a4628c1f2e6a39288fda8eb19c9ec89983e3726e17a09384d9ecc13ef0b4c" }, "no_good_tone3": { "category": "people", "moji": "🙅🏽", + "description": "face with no good gesture tone 3", "unicodeVersion": "8.0", "digest": "959dec1bfdaf37b20a86ab2bcbdbacd3179c87b163042377d966eab47564c0fb" }, "no_good_tone4": { "category": "people", "moji": "🙅🏾", + "description": "face with no good gesture tone 4", "unicodeVersion": "8.0", "digest": "efd931f0080adf2e04129c83a8b24fda0ae7a9fa7c4b463686c0b99023620db8" }, "no_good_tone5": { "category": "people", "moji": "🙅🏿", + "description": "face with no good gesture tone 5", "unicodeVersion": "8.0", "digest": "f35df2b26af9baef47c1f8cc97a1b28a58aa7fcb2a13fdac7b2d9189f1e40105" }, "no_mobile_phones": { "category": "symbols", "moji": "📵", + "description": "no mobile phones", "unicodeVersion": "6.0", "digest": "a472decd6ac7f9777961c09e00458746b2c04965585e3bee4556be3968e55bcd" }, "no_mouth": { "category": "people", "moji": "😶", + "description": "face without mouth", "unicodeVersion": "6.0", "digest": "72dda8b1e3ad4b05d9b095f9bd05e95d7ba013906c68914976a4554e8edf5866" }, "no_pedestrians": { "category": "symbols", "moji": "🚷", + "description": "no pedestrians", "unicodeVersion": "6.0", "digest": "062b4a71b338fe09775e465bfba8ac04efbb3640330e8cabe88f3af62b0f4225" }, "no_smoking": { "category": "symbols", "moji": "🚭", + "description": "no smoking symbol", "unicodeVersion": "6.0", "digest": "ae2ebb331f79f6074091c0ee9cd69fce16d5e12a131d18973fc05520097e14ee" }, "non-potable_water": { "category": "symbols", "moji": "🚱", + "description": "non-potable water symbol", "unicodeVersion": "6.0", "digest": "32eba0a99b498133c2e4450036f768d3dccaaf5b50adc9ad988757adc777a6a1" }, "nose": { "category": "people", "moji": "👃", + "description": "nose", "unicodeVersion": "6.0", "digest": "9f800e24658ea3cebe1144d5d808cf13a88261f1a7f1f81a10d03b3d9d00e541" }, "nose_tone1": { "category": "people", "moji": "👃🏻", + "description": "nose tone 1", "unicodeVersion": "8.0", "digest": "a2d0af22284b1d264eb780943b8360f463996a5c9c9584b8473edf8d442d9173" }, "nose_tone2": { "category": "people", "moji": "👃🏼", + "description": "nose tone 2", "unicodeVersion": "8.0", "digest": "244dcaa8540024cf521f29f36bd48f933bf82f4833e35e6fa0abf113022038f3" }, "nose_tone3": { "category": "people", "moji": "👃🏽", + "description": "nose tone 3", "unicodeVersion": "8.0", "digest": "c935b64866f0d49da52035aa09f36ff56d238eb7f5b92205386451056e8ea74f" }, "nose_tone4": { "category": "people", "moji": "👃🏾", + "description": "nose tone 4", "unicodeVersion": "8.0", "digest": "a87e95fd9319c49e66b6dea0e57319d0ed9921b8d94df037767bf3d5dc7c94f3" }, "nose_tone5": { "category": "people", "moji": "👃🏿", + "description": "nose tone 5", "unicodeVersion": "8.0", "digest": "1e0f9842e0f8ad5805eabd3f35a6038b7a2e49d566a1f5c17271f9cdf467ca60" }, "notebook": { "category": "objects", "moji": "📓", + "description": "notebook", "unicodeVersion": "6.0", "digest": "fc679d3728f86073d1607a926885dd8b0261132f5c4a0322f1e46ea9f95c8cb8" }, "notebook_with_decorative_cover": { "category": "objects", "moji": "📔", + "description": "notebook with decorative cover", "unicodeVersion": "6.0", "digest": "d822eda4b49cbfa399b36f134c1a0b8dcfdd27ed89f12c50bc18f6f0a9aa56ef" }, "notepad_spiral": { "category": "objects", "moji": "🗒", + "description": "spiral note pad", "unicodeVersion": "7.0", "digest": "c6a8e16aa62474cef13e5659fddb4afc57e3f79635e32e6020edbee2b5b50f18" }, "notes": { "category": "symbols", "moji": "🎶", + "description": "multiple musical notes", "unicodeVersion": "6.0", "digest": "98467e0adc134d45676ef1c6c459e5853a9db50c8a6e91b6aec7d449aa737f48" }, "nut_and_bolt": { "category": "objects", "moji": "🔩", + "description": "nut and bolt", "unicodeVersion": "6.0", "digest": "a77bd72f29a7302195dcec240174b15586de79e3204258e3fb401a6ea90563b3" }, "o": { "category": "symbols", "moji": "⭕", + "description": "heavy large circle", "unicodeVersion": "5.2", "digest": "2387e5fd9ae4c2972d40298d32319b8fa55c50dbfc1c04c5c36088213e6951dd" }, "o2": { "category": "symbols", "moji": "🅾", + "description": "negative squared latin capital letter o", "unicodeVersion": "6.0", "digest": "6a9ccb0bf394e4d05ffda19327cee18f7b9ed80367fc7f41c93da9bb7efab0bf" }, "ocean": { "category": "nature", "moji": "🌊", + "description": "water wave", "unicodeVersion": "6.0", "digest": "1a9ca9848d4fb75852addfc10bf84eccf7caa5339714b90e3de4cb6f2518465e" }, "octagonal_sign": { "category": "symbols", "moji": "🛑", + "description": "octagonal sign", "unicodeVersion": "9.0", "digest": "9f6927048e1f9da57f89d1ae1eb86fa4ab7abdbabca756a738a799e948d0b3f9" }, "octopus": { "category": "nature", "moji": "🐙", + "description": "octopus", "unicodeVersion": "6.0", "digest": "0fcc65c12f4b29ea75a8c4823d20838a7e6db6978fdcb536943072aa1460bc59" }, "oden": { "category": "food", "moji": "🍢", + "description": "oden", "unicodeVersion": "6.0", "digest": "089974cb13a0bef6a245fc73029c5ed5153fd4caae0177b835f779e32200b8aa" }, "office": { "category": "travel", "moji": "🏢", + "description": "office building", "unicodeVersion": "6.0", "digest": "3633a2e91036362e273eef4e0cfbdbbb4cb1208afe2cfa110ebef7b78109a66f" }, "oil": { "category": "objects", "moji": "🛢", + "description": "oil drum", "unicodeVersion": "7.0", "digest": "00b94d33bcc9b9e8a5d4bd6e7f7e2fced9497ce05919edd5e58eafbc011c2caa" }, "ok": { "category": "symbols", "moji": "🆗", + "description": "squared ok", "unicodeVersion": "6.0", "digest": "5f320f9b96e98a2f17ebe240daff9b9fd2ae0727cd6c8e4633b1744356e89365" }, "ok_hand": { "category": "people", "moji": "👌", + "description": "ok hand sign", "unicodeVersion": "6.0", "digest": "d63002dce3cc3655b67b8765b7c28d370edba0e3758b2329b60e0e61c4d8e78d" }, "ok_hand_tone1": { "category": "people", "moji": "👌🏻", + "description": "ok hand sign tone 1", "unicodeVersion": "8.0", "digest": "ef1508efcf483b09807554fe0e451c2948224f9deb85463e8e0dad6875b54012" }, "ok_hand_tone2": { "category": "people", "moji": "👌🏼", + "description": "ok hand sign tone 2", "unicodeVersion": "8.0", "digest": "1215a101a082fd8e04c5d2f7e3c59d0f480cb0bedd79aeab5d36676bfe760088" }, "ok_hand_tone3": { "category": "people", "moji": "👌🏽", + "description": "ok hand sign tone 3", "unicodeVersion": "8.0", "digest": "6fe0ed9fb42e86bb2bed4cb37b2acacacda1471fb1ee845ad55e54fb0897fbf4" }, "ok_hand_tone4": { "category": "people", "moji": "👌🏾", + "description": "ok hand sign tone 4", "unicodeVersion": "8.0", "digest": "bfb9041c49d95e901a667264abaf9b398f6c4aa8b52bf5191c122db20c13c020" }, "ok_hand_tone5": { "category": "people", "moji": "👌🏿", + "description": "ok hand sign tone 5", "unicodeVersion": "8.0", "digest": "1c218dc04d698da2cbdd7bea1ca3f845f9b386e967b7247c52f4b0f6ec8f5320" }, "ok_woman": { "category": "people", "moji": "🙆", + "description": "face with ok gesture", "unicodeVersion": "6.0", "digest": "3f8bd4ce2c4497155d697e5a71ebdc9339f65633d07fa9a7903e1bd76cfa4ba1" }, "ok_woman_tone1": { "category": "people", "moji": "🙆🏻", + "description": "face with ok gesture tone1", "unicodeVersion": "8.0", "digest": "1660cd904ccd2ecdc6f4ba00527f7d4ec8c33f3c6183344616f97badae4c3730" }, "ok_woman_tone2": { "category": "people", "moji": "🙆🏼", + "description": "face with ok gesture tone2", "unicodeVersion": "8.0", "digest": "7ba5fddd1e141424fac6778894dfc5af28e125839c58937c69496f99cd2c4002" }, "ok_woman_tone3": { "category": "people", "moji": "🙆🏽", + "description": "face with ok gesture tone3", "unicodeVersion": "8.0", "digest": "1d972b8377c52f598406f59ab1e5be41aaf8f027e1fefba3deda66312ccd6a9b" }, "ok_woman_tone4": { "category": "people", "moji": "🙆🏾", + "description": "face with ok gesture tone4", "unicodeVersion": "8.0", "digest": "a176328d8f53503aa743448968afd21d72ffd3510555526a3fb38d6b30ee7c15" }, "ok_woman_tone5": { "category": "people", "moji": "🙆🏿", + "description": "face with ok gesture tone5", "unicodeVersion": "8.0", "digest": "13cfc1b589c57e81f768ee07a14b737cafc71407a7eb0956728b2ec4b1df14c4" }, "older_man": { "category": "people", "moji": "👴", + "description": "older man", "unicodeVersion": "6.0", "digest": "4c0462b199bf26181c9e4d2d4cb878a32b0294566941212efc67362d0645f948" }, "older_man_tone1": { "category": "people", "moji": "👴🏻", + "description": "older man tone 1", "unicodeVersion": "8.0", "digest": "99baa083f78cb01166d0a928d0b53682be14be04c29fc17bef14aac1a73a61e6" }, "older_man_tone2": { "category": "people", "moji": "👴🏼", + "description": "older man tone 2", "unicodeVersion": "8.0", "digest": "5b4ce713e8820ba517fe92c25f3b93e6a6bf3704d1f982c461d5f31fc02b9d3d" }, "older_man_tone3": { "category": "people", "moji": "👴🏽", + "description": "older man tone 3", "unicodeVersion": "8.0", "digest": "0eff72b3226c3a703c635798ee84129a695c896fa011fe1adbc105312eecc083" }, "older_man_tone4": { "category": "people", "moji": "👴🏾", + "description": "older man tone 4", "unicodeVersion": "8.0", "digest": "ad9ba82b0c5d3b171b0639ee4265370dbddff5e0eeb70729db122659bb8c8f84" }, "older_man_tone5": { "category": "people", "moji": "👴🏿", + "description": "older man tone 5", "unicodeVersion": "8.0", "digest": "5eb0a7467cc40e75752e11fd5126b275863dc037557a0d0d3b24b681e00c2386" }, "older_woman": { "category": "people", "moji": "👵", + "description": "older woman", "unicodeVersion": "6.0", "digest": "c261fdf3b01e0c7d949e177144531add5895197fbadf1acbba8eb17d18766bf6" }, "older_woman_tone1": { "category": "people", "moji": "👵🏻", + "description": "older woman tone 1", "unicodeVersion": "8.0", "digest": "1f2bb9e42270a58194498254da27ac2b7a50edaa771b90ee194ccd6d24660c62" }, "older_woman_tone2": { "category": "people", "moji": "👵🏼", + "description": "older woman tone 2", "unicodeVersion": "8.0", "digest": "2e28198e9b7ac08c55980677ed66655fd899e157f14184958bebd87fcd714940" }, "older_woman_tone3": { "category": "people", "moji": "👵🏽", + "description": "older woman tone 3", "unicodeVersion": "8.0", "digest": "c968be0170f7e0c65d4f796337034cfb1daba897884da6fad85635ab5b6edf67" }, "older_woman_tone4": { "category": "people", "moji": "👵🏾", + "description": "older woman tone 4", "unicodeVersion": "8.0", "digest": "3596a6fa9a643bf79255afcd29657b03850df8499db9669b92ce013af908af44" }, "older_woman_tone5": { "category": "people", "moji": "👵🏿", + "description": "older woman tone 5", "unicodeVersion": "8.0", "digest": "c8998cb3dbd15e22bd1d6dad613d109ce371d9ffca3657e1a8afe5aeb30c1275" }, "om_symbol": { "category": "symbols", "moji": "🕉", + "description": "om symbol", "unicodeVersion": "7.0", "digest": "5ead73bea546ba9ba6da522f7280cc289c75ff5467742bdba31f92d0e1b3f4e6" }, "on": { "category": "symbols", "moji": "🔛", + "description": "on with exclamation mark with left right arrow abo", "unicodeVersion": "6.0", "digest": "9cc61a6b31a30c32dab594191bf23f91e341c4105384ab22158a6d43e6364631" }, "oncoming_automobile": { "category": "travel", "moji": "🚘", + "description": "oncoming automobile", "unicodeVersion": "6.0", "digest": "557c9cacdc3f95215d4f7a6f097a2baa7c007cb9c519492a6717077af4ca6b56" }, "oncoming_bus": { "category": "travel", "moji": "🚍", + "description": "oncoming bus", "unicodeVersion": "6.0", "digest": "059f28ce6bfb337e107db5982cbd2004844450ef20b4a54b9ca3cb738360ab05" }, "oncoming_police_car": { "category": "travel", "moji": "🚔", + "description": "oncoming police car", "unicodeVersion": "6.0", "digest": "aee79306a0d129cfc1980f58db80391eb46d2d7d5f814bf431414dc7680cab72" }, "oncoming_taxi": { "category": "travel", "moji": "🚖", + "description": "oncoming taxi", "unicodeVersion": "6.0", "digest": "84351489fc86d980b8d3eb9ec4e81120fe700b3ac01346daebe2b7aeb9607a55" }, "one": { "category": "symbols", "moji": "1️⃣", + "description": "keycap digit one", "unicodeVersion": "3.0", "digest": "d5d3fff04e68a114ff6464ee06fc831f3f381713045165f62a88d5e8215c195b" }, "open_file_folder": { "category": "objects", "moji": "📂", + "description": "open file folder", "unicodeVersion": "6.0", "digest": "96cfc322ee4903ae8cec07604811742245fd7d14f00bb70276d39d29c48bed28" }, "open_hands": { "category": "people", "moji": "👐", + "description": "open hands sign", "unicodeVersion": "6.0", "digest": "a6c131da2040b48103cea14f280e728675da50fa448d2b3f3438fcbb5bf5596a" }, "open_hands_tone1": { "category": "people", "moji": "👐🏻", + "description": "open hands sign tone 1", "unicodeVersion": "8.0", "digest": "867128dff2fa9b860c10c6b792f989f0c057928783696062378f834c0ef89d85" }, "open_hands_tone2": { "category": "people", "moji": "👐🏼", + "description": "open hands sign tone 2", "unicodeVersion": "8.0", "digest": "487ff2745b03d49bb3b1d0acd86ba530fd8cc3f467ca3fa504f88f0ef1cbbc01" }, "open_hands_tone3": { "category": "people", "moji": "👐🏽", + "description": "open hands sign tone 3", "unicodeVersion": "8.0", "digest": "cb8cddc8b8661f874ac9478289d16cc41406b947bb87f3363df518a588a53e16" }, "open_hands_tone4": { "category": "people", "moji": "👐🏾", + "description": "open hands sign tone 4", "unicodeVersion": "8.0", "digest": "17dcc2c07230846a769f3c79ce618a757c88b9b58c95c6c5b2d7f968814d447d" }, "open_hands_tone5": { "category": "people", "moji": "👐🏿", + "description": "open hands sign tone 5", "unicodeVersion": "8.0", "digest": "36b2493d67c84cea4f3f85a3088c6abcfd35cf99f7aeaeedfafa420ee878e3d2" }, "open_mouth": { "category": "people", "moji": "😮", + "description": "face with open mouth", "unicodeVersion": "6.1", "digest": "1906c5100ae0c8326ca5c4f9422976958a38dadd8d77724d68538a25d9623035" }, "ophiuchus": { "category": "symbols", "moji": "⛎", + "description": "ophiuchus", "unicodeVersion": "6.0", "digest": "6112e2a1656b1cb8bd9a8b0dfa6cbf66d30cae671710a9ef75c821de344aab2b" }, "orange_book": { "category": "objects", "moji": "📙", + "description": "orange book", "unicodeVersion": "6.0", "digest": "41141b08d2beceded21a94795431603c47fd7d42a3a472a2aa8b2bb25fa87ebf" }, "orthodox_cross": { "category": "symbols", "moji": "☦", + "description": "orthodox cross", "unicodeVersion": "1.1", "digest": "c16372102f0169dd6d32eb2b27a633aaee74e4e0fddcf723c15ad97f9dc6075c" }, "outbox_tray": { "category": "objects", "moji": "📤", + "description": "outbox tray", "unicodeVersion": "6.0", "digest": "e47cb481a0ffcb39996f32fd313e19b362a91d8dda15ffca48ac23a3b5bb5baf" }, "owl": { "category": "nature", "moji": "🦉", + "description": "owl", "unicodeVersion": "9.0", "digest": "f62ec1ad23ad9038966eea8d8b79660ac212f291af2e89bcdb0fdc683caf41e5" }, "ox": { "category": "nature", "moji": "🐂", + "description": "ox", "unicodeVersion": "6.0", "digest": "d13bc60552190bb9936bf32d681bdc742439b702a09cfc62137ea09a98624aed" }, "package": { "category": "objects", "moji": "📦", + "description": "package", "unicodeVersion": "6.0", "digest": "e82bf5accebb65136e897c15607eef635fb79fd7b2d8c8e19a9eb00b6786918c" }, "page_facing_up": { "category": "objects", "moji": "📄", + "description": "page facing up", "unicodeVersion": "6.0", "digest": "3884868bdcb2f29615b09a13a30385cbc5269379094a54b5a7e8a5f4e8ce905a" }, "page_with_curl": { "category": "objects", "moji": "📃", + "description": "page with curl", "unicodeVersion": "6.0", "digest": "3d6257670189f841ad1fa45415c34feb2433b2cb35bb435c4ee122ce89b39669" }, "pager": { "category": "objects", "moji": "📟", + "description": "pager", "unicodeVersion": "6.0", "digest": "e21c756cc1c58ebc1b37ebcd38e22a25b31e2e81306c6f18285d6a7671f9eb12" }, "paintbrush": { "category": "objects", "moji": "🖌", + "description": "lower left paintbrush", "unicodeVersion": "7.0", "digest": "fc0da7a25b726b8be9dd6467953e27293d2313a21eeff21424c2a19be614fff2" }, "palm_tree": { "category": "nature", "moji": "🌴", + "description": "palm tree", "unicodeVersion": "6.0", "digest": "90fedafd62fe0abf51325174d0f293ebb9a4794913b9ba93b12f2d0119056df1" }, "pancakes": { "category": "food", "moji": "🥞", + "description": "pancakes", "unicodeVersion": "9.0", "digest": "5256b4832431e8a88555796b1a9726f12d909a26fb2bdc3a0abff76412c45903" }, "panda_face": { "category": "nature", "moji": "🐼", + "description": "panda face", "unicodeVersion": "6.0", "digest": "56a4b84abe983bd6569be1b81ac5e43071015fd308389a16b92231310ae56a5b" }, "paperclip": { "category": "objects", "moji": "📎", + "description": "paperclip", "unicodeVersion": "6.0", "digest": "d1e2ce94a12b7e8b7a9bba49e47ddc7432ec0288545d3b6817c7a499e806e3f0" }, "paperclips": { "category": "objects", "moji": "🖇", + "description": "linked paperclips", "unicodeVersion": "7.0", "digest": "70cefa0d0777f070e393e9f95c24146fe2dd627f30fa3845baa19310d9291fe2" }, "park": { "category": "travel", "moji": "🏞", + "description": "national park", "unicodeVersion": "7.0", "digest": "444dce8014e0817ddd756c36a38adfbbf7ae4c6aa509e4cae291828f0716d5e7" }, "parking": { "category": "symbols", "moji": "🅿", + "description": "negative squared latin capital letter p", "unicodeVersion": "5.2", "digest": "9f1da460a7dd58b26beab8cf701be2691fb812208fbc941c71daa35be1507c2f" }, "part_alternation_mark": { "category": "symbols", "moji": "〽", + "description": "part alternation mark", "unicodeVersion": "3.2", "digest": "956da19353bb38fd4dfe0ab5360679a9035d566858fb5de62887b85c75fb8eef" }, "partly_sunny": { "category": "nature", "moji": "⛅", + "description": "sun behind cloud", "unicodeVersion": "5.2", "digest": "8fb9a6d2caf9e0cce58447762f0dfd6aa0b581b2e83fea6411348e0cbc8cf3c4" }, "passport_control": { "category": "symbols", "moji": "🛂", + "description": "passport control", "unicodeVersion": "6.0", "digest": "d9be6eed2c90e1c89171c42d70a06485fdf86a4c68833371832cc1f6897fadd0" }, "pause_button": { "category": "symbols", "moji": "⏸", + "description": "double vertical bar", "unicodeVersion": "7.0", "digest": "143221d99e82399ed7824b6c5e185700896492058b65c04e4c668291de78b203" }, "peace": { "category": "symbols", "moji": "☮", + "description": "peace symbol", "unicodeVersion": "1.1", "digest": "65181429e373c1f0507bbd98425c1bec0c042d648fb285a392460cbce60f44d4" }, "peach": { "category": "food", "moji": "🍑", + "description": "peach", "unicodeVersion": "6.0", "digest": "768d1f4f29e1e06aff5abb29043be83087ded16427ce6a2d0f682814e665e311" }, "peanuts": { "category": "food", "moji": "🥜", + "description": "peanuts", "unicodeVersion": "9.0", "digest": "e2384846b6e4a6c3a56e991ebb749cb68b330ac00a9e9d888b2c39105ff7ff5d" }, "pear": { "category": "food", "moji": "🍐", + "description": "pear", "unicodeVersion": "6.0", "digest": "b7c9cf90bb979649b863d2f4132f1b51f6f8107d42e08fb8b4033fea32844948" }, "pen_ballpoint": { "category": "objects", "moji": "🖊", + "description": "lower left ballpoint pen", "unicodeVersion": "7.0", "digest": "aacb20b220f26704e10303deeea33be0eec2d3811dcba7795902ca44b6ae9876" }, "pen_fountain": { "category": "objects", "moji": "🖋", + "description": "lower left fountain pen", "unicodeVersion": "7.0", "digest": "3619913eab2b6291f518b40481bb3eca0820d68b0a1b3c11fb6a69c62b75a626" }, "pencil": { "category": "objects", "moji": "📝", + "description": "memo", "unicodeVersion": "6.0", "digest": "accbc3f1439b7faa4411e502385f78a16c8e71851f71fc13582753291ffb507c" }, "pencil2": { "category": "objects", "moji": "✏", + "description": "pencil", "unicodeVersion": "1.1", "digest": "9ca1b56b5726f472b1f1b23050ed163e213916dac379d22e38e4c8358fe871e0" }, "penguin": { "category": "nature", "moji": "🐧", + "description": "penguin", "unicodeVersion": "6.0", "digest": "a1800ab931d6dc84a9c89bfab2c815198025c276d952509c55b18dd20bd9d316" }, "pensive": { "category": "people", "moji": "😔", + "description": "pensive face", "unicodeVersion": "6.0", "digest": "d237deff9f5ead8a0b281b7e5c6f4b82e98cc30c80c86c22c3fdc6160090b2f2" }, "performing_arts": { "category": "activity", "moji": "🎭", + "description": "performing arts", "unicodeVersion": "6.0", "digest": "d7c7bc9213e308ca26286cbbd8012e656b0f9b00293758faf1bfccc4c5ceabed" }, "persevere": { "category": "people", "moji": "😣", + "description": "persevering face", "unicodeVersion": "6.0", "digest": "c361509c9b8663af19a02a1ffff61b1b0d0b4bd75d693ce3d406b0ca1bde1ca0" }, "person_frowning": { "category": "people", "moji": "🙍", + "description": "person frowning", "unicodeVersion": "6.0", "digest": "b37be8bd95f21a6860ad3f171b8086125ab37331b382d87bcdb4cd684800546b" }, "person_frowning_tone1": { "category": "people", "moji": "🙍🏻", + "description": "person frowning tone 1", "unicodeVersion": "8.0", "digest": "3d5e78a367f9673baed2a86bc11cf04fd44394aadb65291fa51ade8dca318427" }, "person_frowning_tone2": { "category": "people", "moji": "🙍🏼", + "description": "person frowning tone 2", "unicodeVersion": "8.0", "digest": "7456c414c65ad6b6f11855f68a2eedc18113526f86862c4373202397cb1bed2c" }, "person_frowning_tone3": { "category": "people", "moji": "🙍🏽", + "description": "person frowning tone 3", "unicodeVersion": "8.0", "digest": "c86cf2d6951f1e6a7c786a74caaf68a777cf00e88023e23849d4383f864ae437" }, "person_frowning_tone4": { "category": "people", "moji": "🙍🏾", + "description": "person frowning tone 4", "unicodeVersion": "8.0", "digest": "944e96ced645ced8db6bb50120c7e37ed46b6960d595cbfe964c81803efa83aa" }, "person_frowning_tone5": { "category": "people", "moji": "🙍🏿", + "description": "person frowning tone 5", "unicodeVersion": "8.0", "digest": "4bd0ea571be6ef9f0493784ef0d12d5e47bc2d6ac610fb42c450bf3d87fb2948" }, "person_with_blond_hair": { "category": "people", "moji": "👱", + "description": "person with blond hair", "unicodeVersion": "6.0", "digest": "a7f94ede2e43308108c2260d83fc10121dda09a67f94a0a840e6d7bba7fd5616" }, "person_with_blond_hair_tone1": { "category": "people", "moji": "👱🏻", + "description": "person with blond hair tone 1", "unicodeVersion": "8.0", "digest": "00a116357a7878554c83e5bade4bddfa9cfabf76a229efa19cbb58e0d216219c" }, "person_with_blond_hair_tone2": { "category": "people", "moji": "👱🏼", + "description": "person with blond hair tone 2", "unicodeVersion": "8.0", "digest": "df509ebe92ed3138b9d5bd4645eff4b13f77f714cf62bb949c59eff1adc00019" }, "person_with_blond_hair_tone3": { "category": "people", "moji": "👱🏽", + "description": "person with blond hair tone 3", "unicodeVersion": "8.0", "digest": "6f328513f440a0c8cd1dc44596a5028fd8f306bdaf57c1e6f3aa94a3aa262b3c" }, "person_with_blond_hair_tone4": { "category": "people", "moji": "👱🏾", + "description": "person with blond hair tone 4", "unicodeVersion": "8.0", "digest": "32df1a577815b009696643ad80d063cc97b35d54add6d4e5517fc936f6da9ee8" }, "person_with_blond_hair_tone5": { "category": "people", "moji": "👱🏿", + "description": "person with blond hair tone 5", "unicodeVersion": "8.0", "digest": "2e270bb39187d8e36a33f4aa4d6045308189595fafc157cf7993e82d7ce93442" }, "person_with_pouting_face": { "category": "people", "moji": "🙎", + "description": "person with pouting face", "unicodeVersion": "6.0", "digest": "57e9a6e5f82121516dc189173f2a63b218f726cd51014e24a18c2bdfeeec3a0b" }, "person_with_pouting_face_tone1": { "category": "people", "moji": "🙎🏻", + "description": "person with pouting face tone1", "unicodeVersion": "8.0", "digest": "d10dadb1ac03fc2e221eff77b4c47935dc0b4fe897af3de30461e7226c3b4bbc" }, "person_with_pouting_face_tone2": { "category": "people", "moji": "🙎🏼", + "description": "person with pouting face tone2", "unicodeVersion": "8.0", "digest": "efface531537ab934b3b96985210a2dac88de812e82e804d6ec12174e536d1cc" }, "person_with_pouting_face_tone3": { "category": "people", "moji": "🙎🏽", + "description": "person with pouting face tone3", "unicodeVersion": "8.0", "digest": "7ff26ece237216b949bfa96d16bd12cfd248c6fd3e4ed89aa6c735c09eafaeff" }, "person_with_pouting_face_tone4": { "category": "people", "moji": "🙎🏾", + "description": "person with pouting face tone4", "unicodeVersion": "8.0", "digest": "045c04105df41d94ff4942133c7394e42ff35ef76c4ccb711497ab77ae6219f2" }, "person_with_pouting_face_tone5": { "category": "people", "moji": "🙎🏿", + "description": "person with pouting face tone5", "unicodeVersion": "8.0", "digest": "783ee37f146fcf61d38af5009f5823cf6526fe99ed891979f454016bce9dd4ba" }, "pick": { "category": "objects", "moji": "⛏", + "description": "pick", "unicodeVersion": "5.2", "digest": "7f0ec5445b4d5c66cf46e2a7332946cce34bd70e9929ac7a119251a7f57f555d" }, "pig": { "category": "nature", "moji": "🐷", + "description": "pig face", "unicodeVersion": "6.0", "digest": "51362570ab36805c8f67622ee4543e38811f8abb20f732a1af2ffbff2d63d042" }, "pig2": { "category": "nature", "moji": "🐖", + "description": "pig", "unicodeVersion": "6.0", "digest": "67010e255f28061b9d9210bcdab6edc072642ad134122a1d0c7e3a6b1795a45b" }, "pig_nose": { "category": "nature", "moji": "🐽", + "description": "pig nose", "unicodeVersion": "6.0", "digest": "0b21cac238bf4910939fbea9bed35552378c1b605a3867d7b85c1556dbda22a9" }, "pill": { "category": "objects", "moji": "💊", + "description": "pill", "unicodeVersion": "6.0", "digest": "cb00be361aaba6dbcf8da58bd20b76221dd75031362ecae99496b088ed413a7f" }, "pineapple": { "category": "food", "moji": "🍍", + "description": "pineapple", "unicodeVersion": "6.0", "digest": "621d4d4c52b59e566c2e29ed7845c8bd2d1da0946577527342097808d170dd70" }, "ping_pong": { "category": "activity", "moji": "🏓", + "description": "table tennis paddle and ball", "unicodeVersion": "8.0", "digest": "943a858bd054c81a08a08951f8351c27c8009b85a9359729c7362868298b58e1" }, "pisces": { "category": "symbols", "moji": "♓", + "description": "pisces", "unicodeVersion": "1.1", "digest": "453c3915122a4b6b32867056d2447be48675a84469145c88d52f8007fcb0861a" }, "pizza": { "category": "food", "moji": "🍕", + "description": "slice of pizza", "unicodeVersion": "6.0", "digest": "169bc6c1e1d7fdab1b8bf2eab0eeec4f9a7ae08b7b9b38f33b0b0c642e72053a" }, "place_of_worship": { "category": "symbols", "moji": "🛐", + "description": "place of worship", "unicodeVersion": "8.0", "digest": "daf271d36a38ee8c0f8b9de84c128ab8b25a5b7df8f107308d0353c961f2c644" }, "play_pause": { "category": "symbols", "moji": "⏯", + "description": "black right-pointing double triangle with double vertical bar", "unicodeVersion": "6.0", "digest": "af1498f34a3d6e0da8bbd26ebaa447e697e2df08c8eb255437cf7905c93f8c42" }, "point_down": { "category": "people", "moji": "👇", + "description": "white down pointing backhand index", "unicodeVersion": "6.0", "digest": "4ecdb3f31c16dc38113b8854ec1a7884613b688a185ebdf967eab9a81018f76d" }, "point_down_tone1": { "category": "people", "moji": "👇🏻", + "description": "white down pointing backhand index tone 1", "unicodeVersion": "8.0", "digest": "c74a7c94367cddbfa840542dc0924adeb0d108be0c7fde8c25fb95d69115d283" }, "point_down_tone2": { "category": "people", "moji": "👇🏼", + "description": "white down pointing backhand index tone 2", "unicodeVersion": "8.0", "digest": "dc4bda0726d85418b974addb42738f437fbb9cf16e5815cdbab3859c4ada6cae" }, "point_down_tone3": { "category": "people", "moji": "👇🏽", + "description": "white down pointing backhand index tone 3", "unicodeVersion": "8.0", "digest": "e460f81a501376d2f0ed1d45e358c5ed03ba049e8f466e4298afb4f3ca6d24dc" }, "point_down_tone4": { "category": "people", "moji": "👇🏾", + "description": "white down pointing backhand index tone 4", "unicodeVersion": "8.0", "digest": "4bc91cd771f24e0f897a9d8b18f323fec9a82da0fc2429c4a7e4e6a9d885a0a3" }, "point_down_tone5": { "category": "people", "moji": "👇🏿", + "description": "white down pointing backhand index tone 5", "unicodeVersion": "8.0", "digest": "7e47c6bc73250f36dc7ae1c1c09e7b41f30647b9d0ff703a53a75cc046b5057d" }, "point_left": { "category": "people", "moji": "👈", + "description": "white left pointing backhand index", "unicodeVersion": "6.0", "digest": "b5a7e864a0016afbadb3bec41f51ecf8c4af73cc20462e1a08b357f90bca6879" }, "point_left_tone1": { "category": "people", "moji": "👈🏻", + "description": "white left pointing backhand index tone 1", "unicodeVersion": "8.0", "digest": "9f1868272a10a2b738c065be5d30241643324550cfd47baf01c7a09060e66d31" }, "point_left_tone2": { "category": "people", "moji": "👈🏼", + "description": "white left pointing backhand index tone 2", "unicodeVersion": "8.0", "digest": "bf0d58c68178a2c2c01d4a6235a1a66b90073cea170f9f6fe2668b6dd68424f7" }, "point_left_tone3": { "category": "people", "moji": "👈🏽", + "description": "white left pointing backhand index tone 3", "unicodeVersion": "8.0", "digest": "34d28c97bc8f9d111d14e328153c4298fc32cf18e39e20aacaec17846645ed90" }, "point_left_tone4": { "category": "people", "moji": "👈🏾", + "description": "white left pointing backhand index tone 4", "unicodeVersion": "8.0", "digest": "c40c8436316915d516c53bb1c98a469528cefd98baa719be7e748c4608cbbcc9" }, "point_left_tone5": { "category": "people", "moji": "👈🏿", + "description": "white left pointing backhand index tone 5", "unicodeVersion": "8.0", "digest": "c410fe32e4ce0ded74845a54b86090e59e5820d457837b16e175b36cc71ecb46" }, "point_right": { "category": "people", "moji": "👉", + "description": "white right pointing backhand index", "unicodeVersion": "6.0", "digest": "44d9251ab41f2f48c2250c44a47f92b3476a71f13fbbbfb637547db837fd5a49" }, "point_right_tone1": { "category": "people", "moji": "👉🏻", + "description": "white right pointing backhand index tone 1", "unicodeVersion": "8.0", "digest": "9fcce259eb81c0b52ec7796b98a1653194e3a9021a1d338df1dbbab7522fc406" }, "point_right_tone2": { "category": "people", "moji": "👉🏼", + "description": "white right pointing backhand index tone 2", "unicodeVersion": "8.0", "digest": "9d00a0b1cfc435674dc56065b3d28d28839196977504cf20581205351d8708f2" }, "point_right_tone3": { "category": "people", "moji": "👉🏽", + "description": "white right pointing backhand index tone 3", "unicodeVersion": "8.0", "digest": "e3026a70630ba73d76892a055a80cac2f78d509faddce737f802d2abefa074ba" }, "point_right_tone4": { "category": "people", "moji": "👉🏾", + "description": "white right pointing backhand index tone 4", "unicodeVersion": "8.0", "digest": "ea508fde90561460361773b4e1b8e80874667b19ac115926206e7c592587cb76" }, "point_right_tone5": { "category": "people", "moji": "👉🏿", + "description": "white right pointing backhand index tone 5", "unicodeVersion": "8.0", "digest": "d59cdb2864eb2929941ecd233f8b8afcddc30fbd4594e5f9acf6386ae06ac12c" }, "point_up": { "category": "people", "moji": "☝", + "description": "white up pointing index", "unicodeVersion": "1.1", "digest": "b69ff4f650989709f2185822d278c7773672bd9eb4a625da80f3038a2b9ce42b" }, "point_up_2": { "category": "people", "moji": "👆", + "description": "white up pointing backhand index", "unicodeVersion": "6.0", "digest": "e83cd9eff2af5125a25f5a306c3ee3cfea240add683b5c36a86a994a8d8c805c" }, "point_up_2_tone1": { "category": "people", "moji": "👆🏻", + "description": "white up pointing backhand index tone 1", "unicodeVersion": "8.0", "digest": "b02ec3e7e04a83bfb769cffb951cbf32aa78e56fa5a51c097f9326df9e08ed33" }, "point_up_2_tone2": { "category": "people", "moji": "👆🏼", + "description": "white up pointing backhand index tone 2", "unicodeVersion": "8.0", "digest": "32994b85c8b4a1383ca985ebc3382be88866cea1ff1315adfb71fb05e992a232" }, "point_up_2_tone3": { "category": "people", "moji": "👆🏽", + "description": "white up pointing backhand index tone 3", "unicodeVersion": "8.0", "digest": "9e263bcfb82ada34ff85291f36e64e66b86760fb11a4e0c554e801644d417d6d" }, "point_up_2_tone4": { "category": "people", "moji": "👆🏾", + "description": "white up pointing backhand index tone 4", "unicodeVersion": "8.0", "digest": "3edc92130a0851ac7b5236772ce7918d088689221df287098688e1ed5b3ff181" }, "point_up_2_tone5": { "category": "people", "moji": "👆🏿", + "description": "white up pointing backhand index tone 5", "unicodeVersion": "8.0", "digest": "cabb3b7da9290840ef59d0c8b22625bdb2e94842f01b0a575ccbc348f3069d77" }, "point_up_tone1": { "category": "people", "moji": "☝🏻", + "description": "white up pointing index tone 1", "unicodeVersion": "8.0", "digest": "e496fda349072f8b321ceb7a251175f7244c3076661f5ede48ea75ba1acf8339" }, "point_up_tone2": { "category": "people", "moji": "☝🏼", + "description": "white up pointing index tone 2", "unicodeVersion": "8.0", "digest": "5a8081323f3baa67e6431e21e16a36559b339f5175d586644e34947f738dd07a" }, "point_up_tone3": { "category": "people", "moji": "☝🏽", + "description": "white up pointing index tone 3", "unicodeVersion": "8.0", "digest": "07bf0cea812eb226b443334e026e13d1ec23e013478f4af862a3919703107842" }, "point_up_tone4": { "category": "people", "moji": "☝🏾", + "description": "white up pointing index tone 4", "unicodeVersion": "8.0", "digest": "1fbbd71433108143ee157d0fdadd183f7f013bafa96f0dd93b181e1fd5fd4af2" }, "point_up_tone5": { "category": "people", "moji": "☝🏿", + "description": "white up pointing index tone 5", "unicodeVersion": "8.0", "digest": "ad068ef32df32f8297955490a9a90590a0f93ed5702a052cd0d8f6484c6cc679" }, "police_car": { "category": "travel", "moji": "🚓", + "description": "police car", "unicodeVersion": "6.0", "digest": "0909be1bd615ae331a7cce71e16dee3ca663c721d5170072c593cb7c76f9f661" }, "poodle": { "category": "nature", "moji": "🐩", + "description": "poodle", "unicodeVersion": "6.0", "digest": "f1742fdf3fd26a8a5cfeaba57026518dacaad364cbd03344c4000a35af13e47a" }, "poop": { "category": "people", "moji": "💩", + "description": "pile of poo", "unicodeVersion": "6.0", "digest": "857a61c872138d359a7fe8257bb26118afa49d75186eca2addb415d07c92b3ec" }, "popcorn": { "category": "food", "moji": "🍿", + "description": "popcorn", "unicodeVersion": "8.0", "digest": "684f1b7ef34ea7ca933aed41569bc6595a19ef0d546a1b7b9e69f8335540b323" }, "post_office": { "category": "travel", "moji": "🏣", + "description": "japanese post office", "unicodeVersion": "6.0", "digest": "54398ee396c1314a7993b1cb1cba264946b5c9d5a7dbb43fd67286854d1d1a0f" }, "postal_horn": { "category": "objects", "moji": "📯", + "description": "postal horn", "unicodeVersion": "6.0", "digest": "0ea12f44f3bae9a14bde3b37361b48bd738d2f613bb1b53a9204959b70e643f8" }, "postbox": { "category": "objects", "moji": "📮", + "description": "postbox", "unicodeVersion": "6.0", "digest": "bbc424ae8d46de380d7023a43ea064002fd614657d00330d3503275827ac87e2" }, "potable_water": { "category": "symbols", "moji": "🚰", + "description": "potable water symbol", "unicodeVersion": "6.0", "digest": "dbe80d9637837377cc2a290da2e895f81a3108cc18b049e3d87212402c1c2098" }, "potato": { "category": "food", "moji": "🥔", + "description": "potato", "unicodeVersion": "9.0", "digest": "a56a69f36f3a0793f278726d92c0cea2960554f3062ef1a0904526a04511d8e1" }, "pouch": { "category": "people", "moji": "👝", + "description": "pouch", "unicodeVersion": "6.0", "digest": "9f012b90310b4a072b6a8fa2c64def087b5f7ffffaafc36e1856ba943a170351" }, "poultry_leg": { "category": "food", "moji": "🍗", + "description": "poultry leg", "unicodeVersion": "6.0", "digest": "1445ec4f5e68a19e5a84e5537dca8190d62409070c954d112e6097f1a6b7f054" }, "pound": { "category": "objects", "moji": "💷", + "description": "banknote with pound sign", "unicodeVersion": "6.0", "digest": "eb11b83eb52adb0a15e69a3bc15788a2dc7825dedee81ac3af84963c9dd517b5" }, "pouting_cat": { "category": "people", "moji": "😾", + "description": "pouting cat face", "unicodeVersion": "6.0", "digest": "8822abedf3499cf98278d7eeea0764d1100ec25cad71b4b2e877f9346f8c8138" }, "pray": { "category": "people", "moji": "🙏", + "description": "person with folded hands", "unicodeVersion": "6.0", "digest": "735b79dab34ac2cf81fd42fdcd7eb1f13c24655e5e343816d5764896c03edeea" }, "pray_tone1": { "category": "people", "moji": "🙏🏻", + "description": "person with folded hands tone 1", "unicodeVersion": "8.0", "digest": "e8b6103450215e8566797f150978355e297deade4eb47a6371f7a7bc558fed9d" }, "pray_tone2": { "category": "people", "moji": "🙏🏼", + "description": "person with folded hands tone 2", "unicodeVersion": "8.0", "digest": "ee8baacd95d7e8dbad8a1f2d9a12e36c98f3d518db5d3b117d0a18290815e62b" }, "pray_tone3": { "category": "people", "moji": "🙏🏽", + "description": "person with folded hands tone 3", "unicodeVersion": "8.0", "digest": "ae8c0caa9aca0a6c44069e76a7535c961d0284cd701812f76bbd2bd79ce2bd53" }, "pray_tone4": { "category": "people", "moji": "🙏🏾", + "description": "person with folded hands tone 4", "unicodeVersion": "8.0", "digest": "64f7b3178b8cd6f6a877ed583539eefe068fa87a0dd658fdcd58c8bc809f7e17" }, "pray_tone5": { "category": "people", "moji": "🙏🏿", + "description": "person with folded hands tone 5", "unicodeVersion": "8.0", "digest": "5bc8cdce937ac06779c87021423efcec4f602aa4a39dba90b00de81033005332" }, "prayer_beads": { "category": "objects", "moji": "📿", + "description": "prayer beads", "unicodeVersion": "8.0", "digest": "80177091264430cbcf7c994fbe5ee17319d1a58d933636cc752a54dafcf98a05" }, "pregnant_woman": { "category": "people", "moji": "🤰", + "description": "pregnant woman", "unicodeVersion": "9.0", "digest": "49abb86409103338bdb6ae43c13a78ca2dc9cd158a26df35eadd0da3c84a4352" }, "pregnant_woman_tone1": { "category": "people", "moji": "🤰🏻", + "description": "pregnant woman tone 1", "unicodeVersion": "9.0", "digest": "5a9f8ed2b631ecf8af111803a5c11f4c156435a5293cb50329c7b98697c8da25" }, "pregnant_woman_tone2": { "category": "people", "moji": "🤰🏼", + "description": "pregnant woman tone 2", "unicodeVersion": "9.0", "digest": "279a2eafff603b11629c955b05f5bd3d7da9a271d4fb3f02e9ccd457b8d2d815" }, "pregnant_woman_tone3": { "category": "people", "moji": "🤰🏽", + "description": "pregnant woman tone 3", "unicodeVersion": "9.0", "digest": "93bb63ec2312db315e3f0065520b715cc413ac0fd65538ec9b5cd97df2a42b20" }, "pregnant_woman_tone4": { "category": "people", "moji": "🤰🏾", + "description": "pregnant woman tone 4", "unicodeVersion": "9.0", "digest": "b8dc3dcec894bfd832a249459b10850f8786b6778d8887a677d1291865623da2" }, "pregnant_woman_tone5": { "category": "people", "moji": "🤰🏿", + "description": "pregnant woman tone 5", "unicodeVersion": "9.0", "digest": "73ee432752f81980f353a7f9b9f7a5ece62512dca08e15c1876b89227face21c" }, "prince": { "category": "people", "moji": "🤴", + "description": "prince", "unicodeVersion": "9.0", "digest": "34a0e0625f0a9825d3674192d6233b6cae4d8130451293df09f91a6a4165869c" }, "prince_tone1": { "category": "people", "moji": "🤴🏻", + "description": "prince tone 1", "unicodeVersion": "9.0", "digest": "ccecdfeccb2ab1fceceae14f3fba875c8c7099785a4c40131c08a697b5b675fc" }, "prince_tone2": { "category": "people", "moji": "🤴🏼", + "description": "prince tone 2", "unicodeVersion": "9.0", "digest": "c373fd3e0c1798415e3d8d88fab6c98c1bbdedcbe6f52f3a3899f6e2124a768d" }, "prince_tone3": { "category": "people", "moji": "🤴🏽", + "description": "prince tone 3", "unicodeVersion": "9.0", "digest": "71d15695ca954d55aa69d3c753c7d31a8ba5329713a8ddbc90dafc11e524c4ef" }, "prince_tone4": { "category": "people", "moji": "🤴🏾", + "description": "prince tone 4", "unicodeVersion": "9.0", "digest": "08f6cb32424f15cc3aaf83c31a5dac7c01a6be2f37ea8f13aed579ce6fb4db19" }, "prince_tone5": { "category": "people", "moji": "🤴🏿", + "description": "prince tone 5", "unicodeVersion": "9.0", "digest": "77d521148efa33fa4d3409693d050fecfd948411e807327484f174e289834649" }, "princess": { "category": "people", "moji": "👸", + "description": "princess", "unicodeVersion": "6.0", "digest": "efabd28480a843c735f0868734da2f9ce28133933b02ab07b645498f494f3f80" }, "princess_tone1": { "category": "people", "moji": "👸🏻", + "description": "princess tone 1", "unicodeVersion": "8.0", "digest": "52b88b99ba64f82e8f36e2a1827c85145e4fcd6863478c2345fe9fa9e8901cdf" }, "princess_tone2": { "category": "people", "moji": "👸🏼", + "description": "princess tone 2", "unicodeVersion": "8.0", "digest": "7e44289404693668f20e681fcdc2e516512d54a69c627eedae958f69dfe6eea9" }, "princess_tone3": { "category": "people", "moji": "👸🏽", + "description": "princess tone 3", "unicodeVersion": "8.0", "digest": "96c9a9857348d7a1a8be899c50d55b352b9a9fd5c65e4777bfa199fe7929d41c" }, "princess_tone4": { "category": "people", "moji": "👸🏾", + "description": "princess tone 4", "unicodeVersion": "8.0", "digest": "67696f96be60f2a36598072172d2db197d007e6c1ac3acef526a5ce6d59bf3f7" }, "princess_tone5": { "category": "people", "moji": "👸🏿", + "description": "princess tone 5", "unicodeVersion": "8.0", "digest": "007f624e2fad91bb57ce32ecd35213a796d71807f3b12f3f1575bf50e6a50eeb" }, "printer": { "category": "objects", "moji": "🖨", + "description": "printer", "unicodeVersion": "7.0", "digest": "5e5307e3dc7ec4e16c9978fb00934c99c4adefca7d32732a244d1f2de71ce6f8" }, "projector": { "category": "objects", "moji": "📽", + "description": "film projector", "unicodeVersion": "7.0", "digest": "7f8e1fdb89584849a56ee34c62cab808af48b7bd4823467d090af4657a2e0420" }, "punch": { "category": "people", "moji": "👊", + "description": "fisted hand sign", "unicodeVersion": "6.0", "digest": "c7e7edf6d64f755db3f02874354f08337b3971aff329476d19ac946e0b421329" }, "punch_tone1": { "category": "people", "moji": "👊🏻", + "description": "fisted hand sign tone 1", "unicodeVersion": "8.0", "digest": "c9ba508b0c36041047473782acfedab5af40dd7946b33daf4d8d54c726e06a11" }, "punch_tone2": { "category": "people", "moji": "👊🏼", + "description": "fisted hand sign tone 2", "unicodeVersion": "8.0", "digest": "d53011cd2f3334c7b3fffdfe1e2b8cc1c832c74306e1ac6d03f954a1309d7d0b" }, "punch_tone3": { "category": "people", "moji": "👊🏽", + "description": "fisted hand sign tone 3", "unicodeVersion": "8.0", "digest": "f7522347094e0130ed8e304678106574dbd7dd2b6b3aeb4d8a7a0fef880920b2" }, "punch_tone4": { "category": "people", "moji": "👊🏾", + "description": "fisted hand sign tone 4", "unicodeVersion": "8.0", "digest": "3e62bdd426f3e6ff175ce3b8dd6f6d3998d9c1506128defa96b528b455295b47" }, "punch_tone5": { "category": "people", "moji": "👊🏿", + "description": "fisted hand sign tone 5", "unicodeVersion": "8.0", "digest": "7d9bff777dc4ec41ac132b1252fa08cf92a398c8dc146c4a5327b45d568982d8" }, "purple_heart": { "category": "symbols", "moji": "💜", + "description": "purple heart", "unicodeVersion": "6.0", "digest": "a6bf01de806525942be480e45a4b2879f91df8129b78a1b8734d4f917bcab773" }, "purse": { "category": "people", "moji": "👛", + "description": "purse", "unicodeVersion": "6.0", "digest": "2b785f36e01875d66cfda2192c8c53606e7224a7c869a4826b62cb61613d60c8" }, "pushpin": { "category": "objects", "moji": "📌", + "description": "pushpin", "unicodeVersion": "6.0", "digest": "c3f7d7008be6bab8dc02284d4d759abf7aafbb3dbbe3a53f0f5b2ff685af88f8" }, "put_litter_in_its_place": { "category": "symbols", "moji": "🚮", + "description": "put litter in its place symbol", "unicodeVersion": "6.0", "digest": "f52a57d6f1bada7b6e6b9a6458597d70cb701c01e1120d8cb1d7ff65e01d405c" }, "question": { "category": "symbols", "moji": "❓", + "description": "black question mark ornament", "unicodeVersion": "6.0", "digest": "40050a1fd29bed321fd601d13dc33de5d6084121f1d873b29bde9dc3d823a310" }, "rabbit": { "category": "nature", "moji": "🐰", + "description": "rabbit face", "unicodeVersion": "6.0", "digest": "678ad953a7ab8f618c59051449a67c965d1f04f42dd6f6669adaf3fadebd080c" }, "rabbit2": { "category": "nature", "moji": "🐇", + "description": "rabbit", "unicodeVersion": "6.0", "digest": "19b1f5108292472434cc7a49efac4ea9275779735c7aeb0f15c36021d5998ca0" }, "race_car": { "category": "travel", "moji": "🏎", + "description": "racing car", "unicodeVersion": "7.0", "digest": "46f4814259d3d17ff35c04110e73e5327aee99f4711cd459ca1ee951508da3a6" }, "racehorse": { "category": "nature", "moji": "🐎", + "description": "horse", "unicodeVersion": "6.0", "digest": "a57b7aca35347ada8225eeee06b70cfd040484104963b4df56ea8fec690576b0" }, "radio": { "category": "objects", "moji": "📻", + "description": "radio", "unicodeVersion": "6.0", "digest": "9245951dd779cdd141089891b15a90d3999a6358acf1fc296aa505100f812108" }, "radio_button": { "category": "symbols", "moji": "🔘", + "description": "radio button", "unicodeVersion": "6.0", "digest": "565bec59198df2592e96564c6e314d3cde33c47b453db1bec6c5d027b5cb4fd9" }, "radioactive": { "category": "symbols", "moji": "☢", + "description": "radioactive sign", "unicodeVersion": "1.1", "digest": "0ed6634057824e0cfd10b2533753e3632b0624341a7eac8d9835706480335581" }, "rage": { "category": "people", "moji": "😡", + "description": "pouting face", "unicodeVersion": "6.0", "digest": "d97ba6bd08eec46dbc7199f530c945b73a87a878e35397b0a3e4f2b45039e89e" }, "railway_car": { "category": "travel", "moji": "🚃", + "description": "railway car", "unicodeVersion": "6.0", "digest": "2cddc08d555e7fc24e312c3d255ed013fbf9cd2974a6918369c32554049ba2be" }, "railway_track": { "category": "travel", "moji": "🛤", + "description": "railway track", "unicodeVersion": "7.0", "digest": "0da351b6d4e75c6beeaef1225e151d9580d4b5c41dfa1cf192715bf3cec981d7" }, "rainbow": { "category": "travel", "moji": "🌈", + "description": "rainbow", "unicodeVersion": "6.0", "digest": "a93aceb54e965f35e397e8c8716b1831614933308d026012d5464ee42783ed4d" }, "raised_back_of_hand": { "category": "people", "moji": "🤚", + "description": "raised back of hand", "unicodeVersion": "9.0", "digest": "20973a697e826625deba5ee3c4f25eb5e1737f2e860ac6fe4ee4d0e0c84b5e12" }, "raised_back_of_hand_tone1": { "category": "people", "moji": "🤚🏻", + "description": "raised back of hand tone 1", "unicodeVersion": "9.0", "digest": "06af5941255ca69d10d99d0a512bbda6141a296453835dbccf259ce0afe1dd3d" }, "raised_back_of_hand_tone2": { "category": "people", "moji": "🤚🏼", + "description": "raised back of hand tone 2", "unicodeVersion": "9.0", "digest": "429ed19555c9e5197b729b3e7bd8013346551051cb0b3fbc8a4372717c9a027d" }, "raised_back_of_hand_tone3": { "category": "people", "moji": "🤚🏽", + "description": "raised back of hand tone 3", "unicodeVersion": "9.0", "digest": "487a1c3f19e77c99b520ec073de2acc4a9e585b739a84b3989f7de85d2c2045c" }, "raised_back_of_hand_tone4": { "category": "people", "moji": "🤚🏾", + "description": "raised back of hand tone 4", "unicodeVersion": "9.0", "digest": "154254d8500c55ec3de698be4a352f9bcf06e2950cabc4eabaccad0f39a1e1e9" }, "raised_back_of_hand_tone5": { "category": "people", "moji": "🤚🏿", + "description": "raised back of hand tone 5", "unicodeVersion": "9.0", "digest": "6e9c0855ecd5f14adca5e5862427c3d39ffcf86f7ddd3aaa1fefc3cefc7483c8" }, "raised_hand": { "category": "people", "moji": "✋", + "description": "raised hand", "unicodeVersion": "6.0", "digest": "5cf11be683aea985d5ba51fbd44722c2327311bfe26b61c3d441c90f5d5a195a" }, "raised_hand_tone1": { "category": "people", "moji": "✋🏻", + "description": "raised hand tone 1", "unicodeVersion": "8.0", "digest": "865afca29b57577fed8fe8c2be57b74254a008c8cf34194680be2759239b5f5d" }, "raised_hand_tone2": { "category": "people", "moji": "✋🏼", + "description": "raised hand tone 2", "unicodeVersion": "8.0", "digest": "832169a0b626a682a58a3b998f68413657b4962c1fab05f1fdc2668e82727210" }, "raised_hand_tone3": { "category": "people", "moji": "✋🏽", + "description": "raised hand tone 3", "unicodeVersion": "8.0", "digest": "3959a873ad7671de82c615c4ed840b011e67baafb2bab7dd16859608d3e83cb1" }, "raised_hand_tone4": { "category": "people", "moji": "✋🏾", + "description": "raised hand tone 4", "unicodeVersion": "8.0", "digest": "db542f65d076ccf3dbfca27cb7c2f135a8bf7a487a81a04873e70172bdfcd579" }, "raised_hand_tone5": { "category": "people", "moji": "✋🏿", + "description": "raised hand tone 5", "unicodeVersion": "8.0", "digest": "88ca884d14baaae48df21d75c22d82fb15bdc395e42026f5ca34cd65e5ae8674" }, "raised_hands": { "category": "people", "moji": "🙌", + "description": "person raising both hands in celebration", "unicodeVersion": "6.0", "digest": "2ee73466a3f5079e542857fe6f5497e9f87753a81854985ce3356a8d3da1d8b8" }, "raised_hands_tone1": { "category": "people", "moji": "🙌🏻", + "description": "person raising both hands in celebration tone 1", "unicodeVersion": "8.0", "digest": "43e73c60f040a66374b8ec98f3629a90d13ae9f472446ed7676cd5573e824f4b" }, "raised_hands_tone2": { "category": "people", "moji": "🙌🏼", + "description": "person raising both hands in celebration tone 2", "unicodeVersion": "8.0", "digest": "fcc5255bb2b06dc82d6878e74cf34e8ce118c70004a06d39a980683772b98c52" }, "raised_hands_tone3": { "category": "people", "moji": "🙌🏽", + "description": "person raising both hands in celebration tone 3", "unicodeVersion": "8.0", "digest": "3ee3e0aafef486e766a166935e8147fb75a7329cfebc96dec876cc45e83a8754" }, "raised_hands_tone4": { "category": "people", "moji": "🙌🏾", + "description": "person raising both hands in celebration tone 4", "unicodeVersion": "8.0", "digest": "78a8cbf6b2b85be4d6b18f0ff6a77f197963117955725fb7e57e0441effb928f" }, "raised_hands_tone5": { "category": "people", "moji": "🙌🏿", + "description": "person raising both hands in celebration tone 5", "unicodeVersion": "8.0", "digest": "2a5ed7334a17172db0cd820a559e7f75df40ec44de6c25d194c76e1b58c634cb" }, "raising_hand": { "category": "people", "moji": "🙋", + "description": "happy person raising one hand", "unicodeVersion": "6.0", "digest": "512750b00704f1ccefd3c757743540b785ad7670dbbe4a2c4dca8d93e6701920" }, "raising_hand_tone1": { "category": "people", "moji": "🙋🏻", + "description": "happy person raising one hand tone1", "unicodeVersion": "8.0", "digest": "2897722f091c273dd3714cff7423c2475bc3070416c28014ca03322b9ece48bc" }, "raising_hand_tone2": { "category": "people", "moji": "🙋🏼", + "description": "happy person raising one hand tone2", "unicodeVersion": "8.0", "digest": "59199b334b3845911382c1f29bd7c0d5ef9d2486417345e265b166ead7d3e1c1" }, "raising_hand_tone3": { "category": "people", "moji": "🙋🏽", + "description": "happy person raising one hand tone3", "unicodeVersion": "8.0", "digest": "f95b338d5efcf14ef12f415a2c1bba93df48628ddc94f34f70c31e1b3c2e1d28" }, "raising_hand_tone4": { "category": "people", "moji": "🙋🏾", + "description": "happy person raising one hand tone4", "unicodeVersion": "8.0", "digest": "951ddbfdb57d5a60551b59b3d0f7ca00a64912f4a101a73afaebd68445cd6cec" }, "raising_hand_tone5": { "category": "people", "moji": "🙋🏿", + "description": "happy person raising one hand tone5", "unicodeVersion": "8.0", "digest": "9370f93704d8f89ca6dc946715eab5e7dba82bf04dd68c00f5c0abb8bc16371e" }, "ram": { "category": "nature", "moji": "🐏", + "description": "ram", "unicodeVersion": "6.0", "digest": "2875ab28e1018b39062aeb0c5ce488c48a98f13e9f2364470a0a700b126604f2" }, "ramen": { "category": "food", "moji": "🍜", + "description": "steaming bowl", "unicodeVersion": "6.0", "digest": "425662a49c4c13577c0de8d45d004e5ba204aaadbaabae62a5c283ecd7a9a2c5" }, "rat": { "category": "nature", "moji": "🐀", + "description": "rat", "unicodeVersion": "6.0", "digest": "14380d65498c6ce037c02a93bca2b24f25a368d85278d6015b8c9f7cd261f8e2" }, "record_button": { "category": "symbols", "moji": "⏺", + "description": "black circle for record", "unicodeVersion": "7.0", "digest": "92be12161ba206bb2e06a39131711c7b17368d55b4aae0b48f0ac5b6b1cde76b" }, "recycle": { "category": "symbols", "moji": "♻", + "description": "black universal recycling symbol", "unicodeVersion": "3.2", "digest": "c377e8537367b05b5de9be860a0fcabd7aed2bf4ba146eefc423671a21530369" }, "red_car": { "category": "travel", "moji": "🚗", + "description": "automobile", "unicodeVersion": "6.0", "digest": "8a99832a195263c0e922af53d52dea37aa3e07032b3c2a1977f8527b4a144b9c" }, "red_circle": { "category": "symbols", "moji": "🔴", + "description": "large red circle", "unicodeVersion": "6.0", "digest": "9dcf0132f6f2cc81702f0e3b15b37984e8439796705bf98f68ba449b3dfa5307" }, "registered": { "category": "symbols", "moji": "®", + "description": "registered sign", "unicodeVersion": "1.1", "digest": "9661b1df529ecb752d130820c55c403e5de263748eb02f7fea327818bc282d94" }, "relaxed": { "category": "people", "moji": "☺", + "description": "white smiling face", "unicodeVersion": "1.1", "digest": "2d5aed4fb8504c6d6660ef8d3bfe0cc053dcd6099c2f53748c202dc970c639bc" }, "relieved": { "category": "people", "moji": "😌", + "description": "relieved face", "unicodeVersion": "6.0", "digest": "b4ce2ba6c220d887fe5e333c05ed773df9b6df0ac456879fd8f5103ff68604a5" }, "reminder_ribbon": { "category": "activity", "moji": "🎗", + "description": "reminder ribbon", "unicodeVersion": "7.0", "digest": "c3de2a7c9350b77a0b86c0dcce9dcd9953ea8a97aa1e7aed149755924742f54d" }, "repeat": { "category": "symbols", "moji": "🔁", + "description": "clockwise rightwards and leftwards open circle arr", "unicodeVersion": "6.0", "digest": "b9512d508613ed0eb3181eb1030f7f6fd6b994476ecdfa308733c6df975fb99e" }, "repeat_one": { "category": "symbols", "moji": "🔂", + "description": "clockwise rightwards and leftwards open circle arr", "unicodeVersion": "6.0", "digest": "53409cf24dd4bb0d7b50ae359f15d06b87b7f4a292ed5c3a09652fa421a90bf2" }, "restroom": { "category": "symbols", "moji": "🚻", + "description": "restroom", "unicodeVersion": "6.0", "digest": "2e7a1bfc9a9d49b0272230a91db7369e24d54bf1de8e683d36b85f1d8c037f77" }, "revolving_hearts": { "category": "symbols", "moji": "💞", + "description": "revolving hearts", "unicodeVersion": "6.0", "digest": "c43d3197cb4cf06659f643638f6c4e91a2889e0f6531b7d81ea826c2a8b784fc" }, "rewind": { "category": "symbols", "moji": "⏪", + "description": "black left-pointing double triangle", "unicodeVersion": "6.0", "digest": "d20c918c1e528ff0947312738501ca9a6fb6ff4016aad07db7a8125d00fd65cd" }, "rhino": { "category": "nature", "moji": "🦏", + "description": "rhinoceros", "unicodeVersion": "9.0", "digest": "163fa3acd78eead72c431a1f48b8465a6d45272a9169560e456d30b4df93dc6b" }, "ribbon": { "category": "objects", "moji": "🎀", + "description": "ribbon", "unicodeVersion": "6.0", "digest": "74315fe907f9f0203afe139cd4552aa442eecfa2a64fac12db3e1292fc5a8828" }, "rice": { "category": "food", "moji": "🍚", + "description": "cooked rice", "unicodeVersion": "6.0", "digest": "f544f12606de59d28739798003f14ebd8869856add8e24496ec5dda3e131daf4" }, "rice_ball": { "category": "food", "moji": "🍙", + "description": "rice ball", "unicodeVersion": "6.0", "digest": "2cba6f5364cd366859bc8948897b65fc97b225ea7973d9be3b24aba388fed8e8" }, "rice_cracker": { "category": "food", "moji": "🍘", + "description": "rice cracker", "unicodeVersion": "6.0", "digest": "ac0f805d41d4f322154c1968bd3ce3e9aabcd39d908182e52fd7d28458dbef92" }, "rice_scene": { "category": "travel", "moji": "🎑", + "description": "moon viewing ceremony", "unicodeVersion": "6.0", "digest": "b942a06d3da0570aca59bab0af57cd8c16863934f12a38f70339fd0a36f675f5" }, "right_facing_fist": { "category": "people", "moji": "🤜", + "description": "right-facing fist", "unicodeVersion": "9.0", "digest": "f815d1cc0c0345ddcc8886ae9c133582d7dc779732ac9b93dde1ab4fdd3b251d" }, "right_facing_fist_tone1": { "category": "people", "moji": "🤜🏻", + "description": "right facing fist tone 1", "unicodeVersion": "9.0", "digest": "0f9269b70cf68071d97389e059a2bdacffd73f2afd2ce6cfd7447bb1a4e9abbb" }, "right_facing_fist_tone2": { "category": "people", "moji": "🤜🏼", + "description": "right facing fist tone 2", "unicodeVersion": "9.0", "digest": "32a9833db853972e49e65aa227fb0512c57362da190aa1cc40e1d64f238e837e" }, "right_facing_fist_tone3": { "category": "people", "moji": "🤜🏽", + "description": "right facing fist tone 3", "unicodeVersion": "9.0", "digest": "be4706f8bb088411f5cbbf9065a0ae5b773c97456bd975c2b6789765657847b9" }, "right_facing_fist_tone4": { "category": "people", "moji": "🤜🏾", + "description": "right facing fist tone 4", "unicodeVersion": "9.0", "digest": "1680862891a9d85c4b6f76232a80e2ef7428bcec93087c86eae2efaba9c6a3f7" }, "right_facing_fist_tone5": { "category": "people", "moji": "🤜🏿", + "description": "right facing fist tone 5", "unicodeVersion": "9.0", "digest": "388715a4bc2178c52bbb3bc2729f57be50acab5d751784c9f3220e86c6b1fbcc" }, "ring": { "category": "people", "moji": "💍", + "description": "ring", "unicodeVersion": "6.0", "digest": "b5322907222797b5e1786209cda88513e76cd397a40f0a7da24847245c95ef9d" }, "robot": { "category": "people", "moji": "🤖", + "description": "robot face", "unicodeVersion": "8.0", "digest": "4d788e6ec89279588b036fca6b17f5a981291681df8f90306ecf5c039de40848" }, "rocket": { "category": "travel", "moji": "🚀", + "description": "rocket", "unicodeVersion": "6.0", "digest": "b82e68a95aa89a6de344d6e256fef86a848ebc91de560b043b3e1f7fd072d57d" }, "rofl": { "category": "people", "moji": "🤣", + "description": "rolling on the floor laughing", "unicodeVersion": "9.0", "digest": "f4f99ba2ac67b97338f904f9384ff03fb832a2e427bf6e74611bf5fee45f1f48" }, "roller_coaster": { "category": "travel", "moji": "🎢", + "description": "roller coaster", "unicodeVersion": "6.0", "digest": "a65e9ace1d7900499777af1225995f17af90a398bb414764c20b6e09a8c23a2c" }, "rolling_eyes": { "category": "people", "moji": "🙄", + "description": "face with rolling eyes", "unicodeVersion": "8.0", "digest": "23dea8100da488a05721a4e82823eb438393b0ea762211c9ecef011d127aa1b7" }, "rooster": { "category": "nature", "moji": "🐓", + "description": "rooster", "unicodeVersion": "6.0", "digest": "2b90c5cf6fa46da13eb77285443d600afcea0c48bd1d215d60167e7dc510da5d" }, "rose": { "category": "nature", "moji": "🌹", + "description": "rose", "unicodeVersion": "6.0", "digest": "73799e459dba188de4de704605d824242feeb65d587c5bf9109acf528d037146" }, "rosette": { "category": "activity", "moji": "🏵", + "description": "rosette", "unicodeVersion": "7.0", "digest": "2537def4deef422d4e669b28b1a0675259306ab38601019df3ec3482b14e52d5" }, "rotating_light": { "category": "travel", "moji": "🚨", + "description": "police cars revolving light", "unicodeVersion": "6.0", "digest": "91fcdb85a752ae904d335a978c7e7936aed4c75d414b35219b5a74430e51555f" }, "round_pushpin": { "category": "objects", "moji": "📍", + "description": "round pushpin", "unicodeVersion": "6.0", "digest": "8ffca77bbdc6f1f726daf3abd6eff338a5ad1aa9b09dbbd8782c1e7ef5452f30" }, "rowboat": { "category": "activity", "moji": "🚣", + "description": "rowboat", "unicodeVersion": "6.0", "digest": "83715d83a061926d4ad3bb569b21f5d337e3ebd4c9bcdfe493e661c12adc0a16" }, "rowboat_tone1": { "category": "activity", "moji": "🚣🏻", + "description": "rowboat tone 1", "unicodeVersion": "8.0", "digest": "e279ac816442c0876fba1f42c700b80f2fb6de671e1a8a9e9d11b71eed5c58e8" }, "rowboat_tone2": { "category": "activity", "moji": "🚣🏼", + "description": "rowboat tone 2", "unicodeVersion": "8.0", "digest": "6a48eba352ed4971d26498b6c622e5772389c89c5205ed02acde8e995dddcc3b" }, "rowboat_tone3": { "category": "activity", "moji": "🚣🏽", + "description": "rowboat tone 3", "unicodeVersion": "8.0", "digest": "875948f6d8354ebd95ce9a66fde30f06a8366dcd89d5ca3e660845f8801e9305" }, "rowboat_tone4": { "category": "activity", "moji": "🚣🏾", + "description": "rowboat tone 4", "unicodeVersion": "8.0", "digest": "8c7ac7346b0020d0ff5e2f4a1efb1b7785eac637f17556663ec33e2335083f0a" }, "rowboat_tone5": { "category": "activity", "moji": "🚣🏿", + "description": "rowboat tone 5", "unicodeVersion": "8.0", "digest": "a399dbb647892b22323e0bf17bc36a9b5f1708ebedf9ba525233ee7b9d48339a" }, "rugby_football": { "category": "activity", "moji": "🏉", + "description": "rugby football", "unicodeVersion": "6.0", "digest": "cc6f00ade3e0bbb7899e7bfb138b57216dd66de26d7967d5ffa501f382ed09f4" }, "runner": { "category": "people", "moji": "🏃", + "description": "runner", "unicodeVersion": "6.0", "digest": "e9af7b591be60ade2049dbada0f062ba2d3e17f02bec76cbd34ce68854a2a10c" }, "runner_tone1": { "category": "people", "moji": "🏃🏻", + "description": "runner tone 1", "unicodeVersion": "8.0", "digest": "21091cbb09c558712ecf63548bf28b7995df42bdb85235088799a517800e52f5" }, "runner_tone2": { "category": "people", "moji": "🏃🏼", + "description": "runner tone 2", "unicodeVersion": "8.0", "digest": "1fe3d194f675a46fe67799394192e66c407dd81163363692c5e7da32ddb9af2b" }, "runner_tone3": { "category": "people", "moji": "🏃🏽", + "description": "runner tone 3", "unicodeVersion": "8.0", "digest": "8cea1bf4ef3be71f42dc5bae978d5b7a197a3851543225349ef0dda29a370537" }, "runner_tone4": { "category": "people", "moji": "🏃🏾", + "description": "runner tone 4", "unicodeVersion": "8.0", "digest": "c33f0b8b5a71d295fb6ba322e79446964a8eca9e4573efd591e4273808b088a0" }, "runner_tone5": { "category": "people", "moji": "🏃🏿", + "description": "runner tone 5", "unicodeVersion": "8.0", "digest": "9f59e6dd0fdf2f17bceb41f5c355b4e6f3c8bb8cbd8af0992f0b5630ff8892e8" }, "running_shirt_with_sash": { "category": "activity", "moji": "🎽", + "description": "running shirt with sash", "unicodeVersion": "6.0", "digest": "7542307d3595aca45e8ccae66b6e58b6e92870144b738263d5379ec6dc992b76" }, "sa": { "category": "symbols", "moji": "🈂", + "description": "squared katakana sa", "unicodeVersion": "6.0", "digest": "6042bcabd1516ef3847d695aba22851c49421244432d256e24eba04e8a223dab" }, "sagittarius": { "category": "symbols", "moji": "♐", + "description": "sagittarius", "unicodeVersion": "1.1", "digest": "a02593e025023f2e82a01c587a8c0bbb1eff88cbcabf535a1558413eb32ed1d5" }, "sailboat": { "category": "travel", "moji": "⛵", + "description": "sailboat", "unicodeVersion": "5.2", "digest": "c95ef4dc939cbdcb757ef3cd90331310e8c0a426add8cc800bae2540148a3195" }, "sake": { "category": "food", "moji": "🍶", + "description": "sake bottle and cup", "unicodeVersion": "6.0", "digest": "0a786075f3d9da48ae91afccf6ae0d097888da9509d354ee1d3cb99afcc88fe4" }, "salad": { "category": "food", "moji": "🥗", + "description": "green salad", "unicodeVersion": "9.0", "digest": "fe321487ab847abe670e68a83f1d9e096129741c689c769ee7de4a65aeac29f8" }, "sandal": { "category": "people", "moji": "👡", + "description": "womans sandal", "unicodeVersion": "6.0", "digest": "03c3077cb4bd900934f9bdf921165b465e5cc9a6bee53e45a091411bceb8892d" }, "santa": { "category": "people", "moji": "🎅", + "description": "father christmas", "unicodeVersion": "6.0", "digest": "178513e3d815917e59958870f5885b3414b43a16b8056980c863a468dfe00179" }, "santa_tone1": { "category": "people", "moji": "🎅🏻", + "description": "father christmas tone 1", "unicodeVersion": "8.0", "digest": "bf900bbc19bbd329229add9326e28e8197b69d6ddceb69f42162b0200fde5d16" }, "santa_tone2": { "category": "people", "moji": "🎅🏼", + "description": "father christmas tone 2", "unicodeVersion": "8.0", "digest": "7340f2171adab97198e3eecac8b0d84c4c2a41f84606301a0d10e9fe655c93d1" }, "santa_tone3": { "category": "people", "moji": "🎅🏽", + "description": "father christmas tone 3", "unicodeVersion": "8.0", "digest": "7368ab75454ec28d8f7d6baef6ad69b5278445a9f50753f6624731bffde32054" }, "santa_tone4": { "category": "people", "moji": "🎅🏾", + "description": "father christmas tone 4", "unicodeVersion": "8.0", "digest": "0ee60188353e0ee7772079c192bebbc6d49e74e63906f840c66da4eb35f4f245" }, "santa_tone5": { "category": "people", "moji": "🎅🏿", + "description": "father christmas tone 5", "unicodeVersion": "8.0", "digest": "e4378a0cc5d21e9b9fe6e35c32d1ebc6fb8c2e1c09554cd096aeaefd3a6eb511" }, "satellite": { "category": "objects", "moji": "📡", + "description": "satellite antenna", "unicodeVersion": "6.0", "digest": "c9d63118dcb445856917bb080460ab695cc78e715dcbba30ba18dffa9e906b27" }, "satellite_orbital": { "category": "travel", "moji": "🛰", + "description": "satellite", "unicodeVersion": "7.0", "digest": "beb2f50e7f2b010e76bed9daa95d7329a93c783d3ebc4f0b797dd721c5e3d32d" }, "saxophone": { "category": "activity", "moji": "🎷", + "description": "saxophone", "unicodeVersion": "6.0", "digest": "dfd138634f6702a3b89b5a2a50016720eef3f800b0d1d8c9fe097808c9491e96" }, "scales": { "category": "objects", "moji": "⚖", + "description": "scales", "unicodeVersion": "4.1", "digest": "2280c026f16c6b92e0daa00bc14e718770f8d231c571ab439bde84d837cf31cc" }, "school": { "category": "travel", "moji": "🏫", + "description": "school", "unicodeVersion": "6.0", "digest": "af198b068a86ccad3daec4c6873e6b4735086c1ecbb3848182e70bae9aa3ee24" }, "school_satchel": { "category": "people", "moji": "🎒", + "description": "school satchel", "unicodeVersion": "6.0", "digest": "f670ae8aea67eb9d8aaa0bf2748c1cc3e503dcc1dbe999133afcdf21af046b24" }, "scissors": { "category": "objects", "moji": "✂", + "description": "black scissors", "unicodeVersion": "1.1", "digest": "95225be28f05d8b5a6b6e6bf58d973f61f183ad4fef55a558dc1b810796b85c8" }, "scooter": { "category": "travel", "moji": "🛴", + "description": "scooter", "unicodeVersion": "9.0", "digest": "4a7db148880398db75e059711cb53edefb6b8fa9d442009f52856b887ab1dde4" }, "scorpion": { "category": "nature", "moji": "🦂", + "description": "scorpion", "unicodeVersion": "8.0", "digest": "d41119d1ea5daf727c17dbea7dadec1718c72fc9f98ae88252161df5fde0938a" }, "scorpius": { "category": "symbols", "moji": "♏", + "description": "scorpius", "unicodeVersion": "1.1", "digest": "a36404b408814c2ecb8fa8b61f5c5432dfcf54cae8c09cc67b8d0fadf7cbdc03" }, "scream": { "category": "people", "moji": "😱", + "description": "face screaming in fear", "unicodeVersion": "6.0", "digest": "916e4903a4b694da4b00f190f872a4e100e7736b7a2e6171fa1636f46bf646e6" }, "scream_cat": { "category": "people", "moji": "🙀", + "description": "weary cat face", "unicodeVersion": "6.0", "digest": "f1d3a6ff538064e7d5e0321bbc33aba44e8da703dc1894ef1403c0cd6d63d781" }, "scroll": { "category": "objects", "moji": "📜", + "description": "scroll", "unicodeVersion": "6.0", "digest": "9b2cb00860bcc2d20017cafb2ed9681b6232dc07273d489d75d53ce29e4ba3ab" }, "seat": { "category": "travel", "moji": "💺", + "description": "seat", "unicodeVersion": "6.0", "digest": "ae68d86fc2a07cae332451b23bd1ceba3f6526a6c56d8c1089777fa4632850e1" }, "second_place": { "category": "activity", "moji": "🥈", + "description": "second place medal", "unicodeVersion": "9.0", "digest": "9e2336fc16e532829b55380252f94655b58817d47c909fc2570002c5b06b9c40" }, "secret": { "category": "symbols", "moji": "㊙", + "description": "circled ideograph secret", "unicodeVersion": "1.1", "digest": "1d0b9adde2657f41421b135962de20820cf4b4eb0204044f9859522ab9d211b0" }, "see_no_evil": { "category": "nature", "moji": "🙈", + "description": "see-no-evil monkey", "unicodeVersion": "6.0", "digest": "3ff66d2e84b36d071d0a34f8e41cfd620a56b83131474ea50ed7803b635551ed" }, "seedling": { "category": "nature", "moji": "🌱", + "description": "seedling", "unicodeVersion": "6.0", "digest": "c0ec5e6d20e1afdc4e78eeddb1301c8b708ad6278e7287a4e4e825417c858e75" }, "selfie": { "category": "people", "moji": "🤳", + "description": "selfie", "unicodeVersion": "9.0", "digest": "2a1bc9f18ad4d6fb893d91c88ef1b2d9bd063dc2bb1a4b08c248c30f52545d4e" }, "selfie_tone1": { "category": "people", "moji": "🤳🏻", + "description": "selfie tone 1", "unicodeVersion": "9.0", "digest": "26dc212ffed30c276bd6a66a72bc4513e68098a2205fb4ca5b51ccfa1de5b544" }, "selfie_tone2": { "category": "people", "moji": "🤳🏼", + "description": "selfie tone 2", "unicodeVersion": "9.0", "digest": "71eceaefda46e3521f374f76693e7fa8f215067498067900080e2925ca94d7de" }, "selfie_tone3": { "category": "people", "moji": "🤳🏽", + "description": "selfie tone 3", "unicodeVersion": "9.0", "digest": "53eabbd4f6b8ebbd2f7af7bf5cd64309c4039ac1c5b2180290a547deaafcebdf" }, "selfie_tone4": { "category": "people", "moji": "🤳🏾", + "description": "selfie tone 4", "unicodeVersion": "9.0", "digest": "0baad378b09652b99c5d458db2e03b4db14a1557db4ea0969806a0ca1d33d40c" }, "selfie_tone5": { "category": "people", "moji": "🤳🏿", + "description": "selfie tone 5", "unicodeVersion": "9.0", "digest": "9a07608f34ec4dad48764a855f83f3965709d7b2fd2342e6dc9ed61f23f4adfd" }, "seven": { "category": "symbols", "moji": "7️⃣", + "description": "keycap digit seven", "unicodeVersion": "3.0", "digest": "ae85172d2c76c44afb4e3b45d277d400abb2dc895244b9abfbd1dac1cd7c53c2" }, "shallow_pan_of_food": { "category": "food", "moji": "🥘", + "description": "shallow pan of food", "unicodeVersion": "9.0", "digest": "7c7ad9d5d3f7226427d310b5853e8257fad899febe58dcbc5adb4677964f5c6d" }, "shamrock": { "category": "nature", "moji": "☘", + "description": "shamrock", "unicodeVersion": "4.1", "digest": "68ed70c26e04a818439a1742d2da6bc169edd02db86b6e6f8014b651f3235488" }, "shark": { "category": "nature", "moji": "🦈", + "description": "shark", "unicodeVersion": "9.0", "digest": "23a2364b6356e7bbb84c138e9cf58e2c68cd8caabb337a0c4d365ce87bf5d2da" }, "shaved_ice": { "category": "food", "moji": "🍧", + "description": "shaved ice", "unicodeVersion": "6.0", "digest": "54048e77268b7548d03088517bf8558d11324db901ca57f9bec93f1873663a74" }, "sheep": { "category": "nature", "moji": "🐑", + "description": "sheep", "unicodeVersion": "6.0", "digest": "c867c8e6e51768f1f51f4fe5abd3fbd5c1d69b01a3cb48b5fb94b6e2338a271c" }, "shell": { "category": "nature", "moji": "🐚", + "description": "spiral shell", "unicodeVersion": "6.0", "digest": "8983652d33ad6ab91195518cecb5a268a1c0ae603d271f0ddd756ff50058ddb3" }, "shield": { "category": "objects", "moji": "🛡", + "description": "shield", "unicodeVersion": "7.0", "digest": "763d0a56a62c51c730ccb0fbea38ab597cbf41a85ab968198e6ec35630d50aa5" }, "shinto_shrine": { "category": "travel", "moji": "⛩", + "description": "shinto shrine", "unicodeVersion": "5.2", "digest": "38a6d756c5aa9703510afa5076d75192f7814bbb6632394d4b8253d9ceda7f8c" }, "ship": { "category": "travel", "moji": "🚢", + "description": "ship", "unicodeVersion": "6.0", "digest": "79c680845892a3e81ec6af2160ee07c29147155943e5daba6c76d04252014c20" }, "shirt": { "category": "people", "moji": "👕", + "description": "t-shirt", "unicodeVersion": "6.0", "digest": "46c7253e15d7cac03699ddb1550fbb7565bbe487310f7e218c0583aa69f9d3c5" }, "shopping_bags": { "category": "objects", "moji": "🛍", + "description": "shopping bags", "unicodeVersion": "7.0", "digest": "95a3f03c675207bb1354270d02a630c204455c47b3edca23c48523a40cf3ea3b" }, "shopping_cart": { "category": "objects", "moji": "🛒", + "description": "shopping trolley", "unicodeVersion": "9.0", "digest": "4599b63f6861cdb4d8272cac84435c24c1d4d6a73c66d51e04a1cd14a1d333e6" }, "shower": { "category": "objects", "moji": "🚿", + "description": "shower", "unicodeVersion": "6.0", "digest": "6b3c767c0eb472d4861c6c3cc2735a5e2c09681872ef42a11dc89f3c80b9da01" }, "shrimp": { "category": "nature", "moji": "🦐", + "description": "shrimp", "unicodeVersion": "9.0", "digest": "b3651f3be3767125076a013fe903854f5b456a8afae865cb219cf528e0f44caa" }, "shrug": { "category": "people", "moji": "🤷", + "description": "shrug", "unicodeVersion": "9.0", "digest": "6e264243cc3b6e396069dea4357a958bdcd4081cb1af0ed6aa47235bef88cf27" }, "shrug_tone1": { "category": "people", "moji": "🤷🏻", + "description": "shrug tone 1", "unicodeVersion": "9.0", "digest": "0567b9fd95c8a857914003a5465a500ca79c8111811d45b865021b1b1d92d0b1" }, "shrug_tone2": { "category": "people", "moji": "🤷🏼", + "description": "shrug tone 2", "unicodeVersion": "9.0", "digest": "1557c2f5e3d4599c806d74c0b78afcca940678787534b6862bb89a20601bac8a" }, "shrug_tone3": { "category": "people", "moji": "🤷🏽", + "description": "shrug tone 3", "unicodeVersion": "9.0", "digest": "f02754541a7bf74ba7eebe6c27daf1e3e1dac25172c35b8ba45641e278dfda3d" }, "shrug_tone4": { "category": "people", "moji": "🤷🏾", + "description": "shrug tone 4", "unicodeVersion": "9.0", "digest": "2b5121164cb5f4e253d8fb31f6445cf8afaf30dba41732edc511440cdb78d15c" }, "shrug_tone5": { "category": "people", "moji": "🤷🏿", + "description": "shrug tone 5", "unicodeVersion": "9.0", "digest": "62d99a26bbad479f574f66208c41b9960cd41fb9d79d3a13fbdaa44682077115" }, "signal_strength": { "category": "symbols", "moji": "📶", + "description": "antenna with bars", "unicodeVersion": "6.0", "digest": "2c6f04ba4ecd2d2d423e19eb52cfbfd253f4db6e0908d91c1af4ea6192597447" }, "six": { "category": "symbols", "moji": "6️⃣", + "description": "keycap digit six", "unicodeVersion": "3.0", "digest": "cede9324261208d0fd5d00fcdfc0df0331944bd9cff4f40b30a582a641526c1c" }, "six_pointed_star": { "category": "symbols", "moji": "🔯", + "description": "six pointed star with middle dot", "unicodeVersion": "6.0", "digest": "9203e3b4f08af439ae0bfb6a7b29a02dceb027b6c2dc5463b524dfd314cbff4e" }, "ski": { "category": "activity", "moji": "🎿", + "description": "ski and ski boot", "unicodeVersion": "6.0", "digest": "80f0ca8660ba373fef823af9e98e148c4ddb1e217eb6d0a0ea2bae2288b57570" }, "skier": { "category": "activity", "moji": "⛷", + "description": "skier", "unicodeVersion": "5.2", "digest": "4fff0aa155367f551a59aed9657b8afa159173882b25db9cd8434293d1eed76d" }, "skull": { "category": "people", "moji": "💀", + "description": "skull", "unicodeVersion": "6.0", "digest": "cdd2031164281bf2b0083df4479651d96bc16d11e44bac4deaf402a9c0d6f40a" }, "skull_crossbones": { "category": "objects", "moji": "☠", + "description": "skull and crossbones", "unicodeVersion": "1.1", "digest": "ae764ba21a1fcc4409f4cc9e75a261d70b87548f64158dbd3451374ad5724123" }, "sleeping": { "category": "people", "moji": "😴", + "description": "sleeping face", "unicodeVersion": "6.1", "digest": "1050a011509b56735c9f30a6fccc876256e2a4546dc6052e518151c8aca4b526" }, "sleeping_accommodation": { "category": "objects", "moji": "🛌", + "description": "sleeping accommodation", "unicodeVersion": "7.0", "digest": "2ce42c027d1d0947abc403c359fd668a7bc44f5ead2582e97f3db7dd4e22e5d5" }, "sleepy": { "category": "people", "moji": "😪", + "description": "sleepy face", "unicodeVersion": "6.0", "digest": "2ee9bb1f72ef99e0e33095ec2bbf7a58ffea0ff7d40b840f4cdba57be9de74b0" }, "slight_frown": { "category": "people", "moji": "🙁", + "description": "slightly frowning face", "unicodeVersion": "7.0", "digest": "d71d564a6c2d366a8e28a78ef4e07d387a77037fe8c99aa0ea1571299dc490c9" }, "slight_smile": { "category": "people", "moji": "🙂", + "description": "slightly smiling face", "unicodeVersion": "7.0", "digest": "10f4b66a755f5c78762a330f20d1866e4a22f3f1d495161d758d3bab8d2f36fe" }, "slot_machine": { "category": "activity", "moji": "🎰", + "description": "slot machine", "unicodeVersion": "6.0", "digest": "914184788f8cd865cd074dca25c22acee31f5498117bd9a6e78cae67e6601652" }, "small_blue_diamond": { "category": "symbols", "moji": "🔹", + "description": "small blue diamond", "unicodeVersion": "6.0", "digest": "0b56d8e6b5ddf1f49fcc76e45e5fb2ee9f99ae6ffe682c26eaea4d9b7faac36c" }, "small_orange_diamond": { "category": "symbols", "moji": "🔸", + "description": "small orange diamond", "unicodeVersion": "6.0", "digest": "a2235830550e289c1608f2dcf5ede48f5c1a0eff45570699c39708c9677ab950" }, "small_red_triangle": { "category": "symbols", "moji": "🔺", + "description": "up-pointing red triangle", "unicodeVersion": "6.0", "digest": "8c2985c4e9ce42d2f3b35539b879bc36206c5ef749f39fbd1eac51bd2676e1e5" }, "small_red_triangle_down": { "category": "symbols", "moji": "🔻", + "description": "down-pointing red triangle", "unicodeVersion": "6.0", "digest": "46bd328df2fbf5d0597596bbf00d2d5f6e0c65bcb8f3fb325df8ba0c25e445b5" }, "smile": { "category": "people", "moji": "😄", + "description": "smiling face with open mouth and smiling eyes", "unicodeVersion": "6.0", "digest": "14905c372d5bf7719bd727c9efae31a03291acec79801652a23710c6848c5d14" }, "smile_cat": { "category": "people", "moji": "😸", + "description": "grinning cat face with smiling eyes", "unicodeVersion": "6.0", "digest": "c35b76d6df100edb4022d762f47abfeb9f5e70886960c1d25908bd5d57ccb47e" }, "smiley": { "category": "people", "moji": "😃", + "description": "smiling face with open mouth", "unicodeVersion": "6.0", "digest": "a89f31eb9d814636852517a7f4eadec59195e2ac2cc9f8d124f1a1cc0f775b4a" }, "smiley_cat": { "category": "people", "moji": "😺", + "description": "smiling cat face with open mouth", "unicodeVersion": "6.0", "digest": "3e66a113c5e3e73fb94be29084cb27986b6bdb0e78ab44785bf2a35a550e71bf" }, "smiling_imp": { "category": "people", "moji": "😈", + "description": "smiling face with horns", "unicodeVersion": "6.0", "digest": "3e02131d16525938f6facc7e097365dec7e13c8a0049a3be35fc29c80cc291b3" }, "smirk": { "category": "people", "moji": "😏", + "description": "smirking face", "unicodeVersion": "6.0", "digest": "3c180d46f5574d6fca3bb68eb02517da60b7008843cb3e90f2f9620d0c8ee943" }, "smirk_cat": { "category": "people", "moji": "😼", + "description": "cat face with wry smile", "unicodeVersion": "6.0", "digest": "0683c7f73e1f65984e91313607d7cca21d99acd4b2e9932f00e0fffd0ce90742" }, "smoking": { "category": "objects", "moji": "🚬", + "description": "smoking symbol", "unicodeVersion": "6.0", "digest": "baa9cb444bf0fe5c74358f981b19bc9e5c0415ced7f042baf93642282476ea61" }, "snail": { "category": "nature", "moji": "🐌", + "description": "snail", "unicodeVersion": "6.0", "digest": "5733bf3672ae4b2b3e090fa670aeac70dcbcc04ca5b13abc8c8e53b8b3d4ff33" }, "snake": { "category": "nature", "moji": "🐍", + "description": "snake", "unicodeVersion": "6.0", "digest": "18da2d97c771149ef5454dd23470e900903a62ab93f9e2ce301aad5a8181d773" }, "sneezing_face": { "category": "people", "moji": "🤧", + "description": "sneezing face", "unicodeVersion": "9.0", "digest": "c20ef571dc7e35572fe3c18b7845aefc89af083ea925c48a29de3b7387af6e17" }, "snowboarder": { "category": "activity", "moji": "🏂", + "description": "snowboarder", "unicodeVersion": "6.0", "digest": "c6e074139b851aa53b1ba6464d84da14b3da7412fc44c6c196a8469d76915c19" }, "snowflake": { "category": "nature", "moji": "❄", + "description": "snowflake", "unicodeVersion": "1.1", "digest": "6556c918e181df01ba849e76c43972d5310439971e5d8fc2409d112c05bf0028" }, "snowman": { "category": "nature", "moji": "⛄", + "description": "snowman without snow", "unicodeVersion": "5.2", "digest": "6137456b2335e88e09c1859615eb22bb636355ef438f7a3949ad2f3d54478dd3" }, "snowman2": { "category": "nature", "moji": "☃", + "description": "snowman", "unicodeVersion": "1.1", "digest": "33ec75c22a13c81fa3c6b24a77ac1a08dc0dbe70b3716cf17b6702014d8a63fe" }, "sob": { "category": "people", "moji": "😭", + "description": "loudly crying face", "unicodeVersion": "6.0", "digest": "d1ed4b31861f9f9fd4e9c95a9c17530e2320a1b4cad6ececb1545ce25d65e4ce" }, "soccer": { "category": "activity", "moji": "⚽", + "description": "soccer ball", "unicodeVersion": "5.2", "digest": "6a3f2e6a9a0b64c3fbf8705995792091daf386a4112dba75507a1f556f662f84" }, "soon": { "category": "symbols", "moji": "🔜", + "description": "soon with rightwards arrow above", "unicodeVersion": "6.0", "digest": "a49d1bcfbac3e6ccc05b9a9863eff74b0eb8b4d4b22b8b0f7b2787fcba1c73cc" }, "sos": { "category": "symbols", "moji": "🆘", + "description": "squared sos", "unicodeVersion": "6.0", "digest": "2fa7e0274383aeed6019eb9177e778d7aab8b88575b078b0ffeb77cd18df14b3" }, "sound": { "category": "symbols", "moji": "🔉", + "description": "speaker with one sound wave", "unicodeVersion": "6.0", "digest": "faaca7b315b2495cbc381468580d25f1d11362441c35bb43d8a914f2ec8202d2" }, "space_invader": { "category": "activity", "moji": "👾", + "description": "alien monster", "unicodeVersion": "6.0", "digest": "e75379cb5063f9a8861d762ad1886097c1697fbb61f2e4e8f531047955a4a2dd" }, "spades": { "category": "symbols", "moji": "♠", + "description": "black spade suit", "unicodeVersion": "1.1", "digest": "2c4d20f6a4893cfc62498d3f1f8f67577f39ed09f3e6682d8cb9cd8f365d30da" }, "spaghetti": { "category": "food", "moji": "🍝", + "description": "spaghetti", "unicodeVersion": "6.0", "digest": "6d3451dc0faa1913539edb99261448f51735f269b61193c53dfe63466c0191e8" }, "sparkle": { "category": "symbols", "moji": "❇", + "description": "sparkle", "unicodeVersion": "1.1", "digest": "7131163cd6c2f879110c86e9f068c33cf580f7c4b619449c41851fe6083402ee" }, "sparkler": { "category": "travel", "moji": "🎇", + "description": "firework sparkler", "unicodeVersion": "6.0", "digest": "88539ed8a13bd66e0c265c0913bd3ec2ddc4d95484323595713beb102221a1f6" }, "sparkles": { "category": "nature", "moji": "✨", + "description": "sparkles", "unicodeVersion": "6.0", "digest": "cf84d16b1c0a381d5a7ae79031872747c9a6887eab6e92cc4a10a4b8600ef506" }, "sparkling_heart": { "category": "symbols", "moji": "💖", + "description": "sparkling heart", "unicodeVersion": "6.0", "digest": "b80b1ddef83b6528b309a194f6f2faf5acab603daeb9254523efc2b941bcb6d2" }, "speak_no_evil": { "category": "nature", "moji": "🙊", + "description": "speak-no-evil monkey", "unicodeVersion": "6.0", "digest": "d2d7cfb4d471928a496bdc146890adc8422a68500b68115630b24c125d18e81f" }, "speaker": { "category": "symbols", "moji": "🔈", + "description": "speaker", "unicodeVersion": "6.0", "digest": "dbca5f7181728d2ad67ff76fd566ffbdf53e333e7eeed341f54668bd47969413" }, "speaking_head": { "category": "people", "moji": "🗣", + "description": "speaking head in silhouette", "unicodeVersion": "7.0", "digest": "4be1af79b4506c00af4df64663413bcbae195dab0bc63c5011feb8f9663ed544" }, "speech_balloon": { "category": "symbols", "moji": "💬", + "description": "speech balloon", "unicodeVersion": "6.0", "digest": "817100d9979456e7d2f253ac22e13b7a2302dc1590566214915b003e403c53ca" }, "speedboat": { "category": "travel", "moji": "🚤", + "description": "speedboat", "unicodeVersion": "6.0", "digest": "a523b2320f0b24be1e9fdbc1ff828e28d8fd9a64d51e5888ab453ef0bc9f0576" }, "spider": { "category": "nature", "moji": "🕷", + "description": "spider", "unicodeVersion": "7.0", "digest": "8411eac0c1b80926fd93cc1d6423e00b05d04c485b79ee232da8f1714e899a37" }, "spider_web": { "category": "nature", "moji": "🕸", + "description": "spider web", "unicodeVersion": "7.0", "digest": "2434bdfbe56dcc4a43699dd59b638af431486b52fb1d6d685451f3b231b2be23" }, "spoon": { "category": "food", "moji": "🥄", + "description": "spoon", "unicodeVersion": "9.0", "digest": "4fa31d59e5bffd2c45a8e01fcd5652e78a5691cbfa744e69882bc67173ddea05" }, "spy": { "category": "people", "moji": "🕵", + "description": "sleuth or spy", "unicodeVersion": "7.0", "digest": "99fe3cdeff934726ee5855b0e401bf32570084aaad4eb10df837fd410ca742aa" }, "spy_tone1": { "category": "people", "moji": "🕵🏻", + "description": "sleuth or spy tone 1", "unicodeVersion": "8.0", "digest": "1720a99064061c43c7647b6bd517efa2ee2621b355a644adfb347d62849366a2" }, "spy_tone2": { "category": "people", "moji": "🕵🏼", + "description": "sleuth or spy tone 2", "unicodeVersion": "8.0", "digest": "23ff0026723f2b5a46fbfb55e24c4a4a33af2bd96808b3ea3af76aae99965d68" }, "spy_tone3": { "category": "people", "moji": "🕵🏽", + "description": "sleuth or spy tone 3", "unicodeVersion": "8.0", "digest": "1d0cb3d54fb61e4763a4f0642ef32094bdd40832be0d42799ce9ba69773616df" }, "spy_tone4": { "category": "people", "moji": "🕵🏾", + "description": "sleuth or spy tone 4", "unicodeVersion": "8.0", "digest": "e36a4b52df6cb954fab9d9128111f1301c6d46bdeacf51993ffb5bb354cd0ad3" }, "spy_tone5": { "category": "people", "moji": "🕵🏿", + "description": "sleuth or spy tone 5", "unicodeVersion": "8.0", "digest": "ffc6fefd9a537124ebf0a9ddf387414dce1291335026064644f6cf9315591129" }, "squid": { "category": "nature", "moji": "🦑", + "description": "squid", "unicodeVersion": "9.0", "digest": "65a1b318c2c506b9d26cfd8282a5cf9922109595c8d12e92c3f7481ac7c08c49" }, "stadium": { "category": "travel", "moji": "🏟", + "description": "stadium", "unicodeVersion": "7.0", "digest": "73bf955e767ba1518c9c92b2ba59a2aa1ec4b018652dffd97bcd74832a33789f" }, "star": { "category": "nature", "moji": "⭐", + "description": "white medium star", "unicodeVersion": "5.1", "digest": "d78e5c1b78caed103e100150c10b08a9ca3ee30c243943d6fc3cc08f422122e9" }, "star2": { "category": "nature", "moji": "🌟", + "description": "glowing star", "unicodeVersion": "6.0", "digest": "f91ac4afe3f5d4a52847ae8b4a9704b591e00399aebba553d150d7e34ee939fa" }, "star_and_crescent": { "category": "symbols", "moji": "☪", + "description": "star and crescent", "unicodeVersion": "1.1", "digest": "1bf3d29e50034f5e7c0dccff0a3a533b74bfa9b489e357b2739a473311f1332a" }, "star_of_david": { "category": "symbols", "moji": "✡", + "description": "star of david", "unicodeVersion": "1.1", "digest": "28a0bd0eeac9d0835ceb8425d72c2472464e863dd09b76a0ddc1c08cf1986402" }, "stars": { "category": "travel", "moji": "🌠", + "description": "shooting star", "unicodeVersion": "6.0", "digest": "837d9045316b8fb5e533457eac61241534f641eb78d8cb75f688f80fb8e8a7f0" }, "station": { "category": "travel", "moji": "🚉", + "description": "station", "unicodeVersion": "6.0", "digest": "27a163ac0aea4ed247a121cae826eafc475977c68b0d888e9405bea14326ff56" }, "statue_of_liberty": { "category": "travel", "moji": "🗽", + "description": "statue of liberty", "unicodeVersion": "6.0", "digest": "f5a43599ab3f24ed3a78a745e06e2ac3e33107a292386ad81c67935ee5b22493" }, "steam_locomotive": { "category": "travel", "moji": "🚂", + "description": "steam locomotive", "unicodeVersion": "6.0", "digest": "52ad0073f37b978faf3884fb193046f2b0614e1557bbcc9de1b020e42aff2dba" }, "stew": { "category": "food", "moji": "🍲", + "description": "pot of food", "unicodeVersion": "6.0", "digest": "c16f61236db314ad8d9f2dd241ec1e15c8d64e5872cce93ec4d0996490dd39df" }, "stop_button": { "category": "symbols", "moji": "⏹", + "description": "black square for stop", "unicodeVersion": "7.0", "digest": "83f9d0da3ad845fef41b4e8336815d30e9c8f042ab2a8340894ade2f428fc98a" }, "stopwatch": { "category": "objects", "moji": "⏱", + "description": "stopwatch", "unicodeVersion": "6.0", "digest": "9b6b9491a24d8ab4f896eb876da7973f028bd5e7c51a3767ba7e61bb6fbb2be0" }, "straight_ruler": { "category": "objects", "moji": "📏", + "description": "straight ruler", "unicodeVersion": "6.0", "digest": "cee31101767bd3f961363599924dc3790675d05a1285a8396428d2f91771c111" }, "strawberry": { "category": "food", "moji": "🍓", + "description": "strawberry", "unicodeVersion": "6.0", "digest": "5750a15e12f21259286ddbc3a8222a385b3b97a9f368897f42dd000060343174" }, "stuck_out_tongue": { "category": "people", "moji": "😛", + "description": "face with stuck-out tongue", "unicodeVersion": "6.1", "digest": "92dc42980a6dfdd7204fc874a762d6a0bbf0fdbfb5a7c0698fca04782e99fde6" }, "stuck_out_tongue_closed_eyes": { "category": "people", "moji": "😝", + "description": "face with stuck-out tongue and tightly-closed eyes", "unicodeVersion": "6.0", "digest": "434d25ac24cad7ba699eae876a25d9a99b584449cca50b124bf6aa7f20a83d51" }, "stuck_out_tongue_winking_eye": { "category": "people", "moji": "😜", + "description": "face with stuck-out tongue and winking eye", "unicodeVersion": "6.0", "digest": "dbacd6428a2a2933212e6a4dc0c7f302177fb23b963626ccb26f27f91737f03d" }, "stuffed_flatbread": { "category": "food", "moji": "🥙", + "description": "stuffed flatbread", "unicodeVersion": "9.0", "digest": "9f841f2520640d69be4f20a3199023d5811842b28556b5e1152e5ec11f0fda07" }, "sun_with_face": { "category": "nature", "moji": "🌞", + "description": "sun with face", "unicodeVersion": "6.0", "digest": "7256ff5263006c64c03f1eb66e3ddb56d67d785d65dacc37aa886d0cd4be63be" }, "sunflower": { "category": "nature", "moji": "🌻", + "description": "sunflower", "unicodeVersion": "6.0", "digest": "27d1161f50f932a6b26c404cf2e8f7083683ed0f2382d62b7472acccaa6eb695" }, "sunglasses": { "category": "people", "moji": "😎", + "description": "smiling face with sunglasses", "unicodeVersion": "6.0", "digest": "966684382e5c59e98319e4c0ea7c304c61c2638ad5408faa49ce2c83c4416757" }, "sunny": { "category": "nature", "moji": "☀", + "description": "black sun with rays", "unicodeVersion": "1.1", "digest": "460fea4cbbdd1595450c1033a2ee5de7fea2e2f147822efa49f7e204812415aa" }, "sunrise": { "category": "travel", "moji": "🌅", + "description": "sunrise", "unicodeVersion": "6.0", "digest": "7718a49636b0cdd1862ed67c7a9d6e72f471c2591ff0d912485b1be55d1ea115" }, "sunrise_over_mountains": { "category": "travel", "moji": "🌄", + "description": "sunrise over mountains", "unicodeVersion": "6.0", "digest": "743d0701cdbe2a814962363813c3153d3c5e62c3e410349f56d49dbb9581f356" }, "surfer": { "category": "activity", "moji": "🏄", + "description": "surfer", "unicodeVersion": "6.0", "digest": "bb440775e9213430942015c37db8de58b5a561ee971b2a0f3993fc3f1d2554d4" }, "surfer_tone1": { "category": "activity", "moji": "🏄🏻", + "description": "surfer tone 1", "unicodeVersion": "8.0", "digest": "a4937b030aca30b68bb644f37cf63c38aebce3c00b57d1c8a0ffe596b57d2f1e" }, "surfer_tone2": { "category": "activity", "moji": "🏄🏼", + "description": "surfer tone 2", "unicodeVersion": "8.0", "digest": "1c2a954a9c5284dedf0327d6f3c954c9fdd3953b848076d298874775ad8bf0a3" }, "surfer_tone3": { "category": "activity", "moji": "🏄🏽", + "description": "surfer tone 3", "unicodeVersion": "8.0", "digest": "418a3408b9ab026124f067c8597b500217e56bc28d9844a29eea5eee6f604ff8" }, "surfer_tone4": { "category": "activity", "moji": "🏄🏾", + "description": "surfer tone 4", "unicodeVersion": "8.0", "digest": "530870b9ac9f4d45ff750e264feb90b44fb93ca2852f323987b06f5f12fb5a4d" }, "surfer_tone5": { "category": "activity", "moji": "🏄🏿", + "description": "surfer tone 5", "unicodeVersion": "8.0", "digest": "40e11b1ae652cfd085d083377f1da24160065ed1b67403c6fa4655e6e44169ec" }, "sushi": { "category": "food", "moji": "🍣", + "description": "sushi", "unicodeVersion": "6.0", "digest": "b924c621236ca3284b349b0509ae1043f2fc2c7f6d67615716f9717ada78c992" }, "suspension_railway": { "category": "travel", "moji": "🚟", + "description": "suspension railway", "unicodeVersion": "6.0", "digest": "cd3d21da79864f0c018b863e82fb0561fff3c5e3c065303cfcb89c3663d638ba" }, "sweat": { "category": "people", "moji": "😓", + "description": "face with cold sweat", "unicodeVersion": "6.0", "digest": "1aa771479aa1ac5eeea4bafbe93ebd85a0f692f6d869034f31e25b689c2e264d" }, "sweat_drops": { "category": "nature", "moji": "💦", + "description": "splashing sweat symbol", "unicodeVersion": "6.0", "digest": "b575b85415bc9852cf6415d417ebf799167fde03c6819ebcaa24ae1b3dde8dab" }, "sweat_smile": { "category": "people", "moji": "😅", + "description": "smiling face with open mouth and cold sweat", "unicodeVersion": "6.0", "digest": "171b0d0845d46c33bedb6d3b39fb1ff366e22ba90685eedabebd91bb2b0680de" }, "sweet_potato": { "category": "food", "moji": "🍠", + "description": "roasted sweet potato", "unicodeVersion": "6.0", "digest": "4b91920f0b87d42763313bc476f4c821a74e4c12dc1c92165a859dddeaaf8844" }, "swimmer": { "category": "activity", "moji": "🏊", + "description": "swimmer", "unicodeVersion": "6.0", "digest": "2c4ed4a51aad99d9957ae11a219d5164db9748fc3a65002c6085a9f15adfa9e2" }, "swimmer_tone1": { "category": "activity", "moji": "🏊🏻", + "description": "swimmer tone 1", "unicodeVersion": "8.0", "digest": "48588f129ee4af52ca2e0f4594213391978601087cd607896b2f979ca077284b" }, "swimmer_tone2": { "category": "activity", "moji": "🏊🏼", + "description": "swimmer tone 2", "unicodeVersion": "8.0", "digest": "fff209448524bd1ef4d6decabf6c1ead94c8d3d5b1bfb5e54f20cc8e139232fc" }, "swimmer_tone3": { "category": "activity", "moji": "🏊🏽", + "description": "swimmer tone 3", "unicodeVersion": "8.0", "digest": "2003932cb2cf4ae9a10b23338bf375a9293fb18c0ecf91bdfae73be6eebb3800" }, "swimmer_tone4": { "category": "activity", "moji": "🏊🏾", + "description": "swimmer tone 4", "unicodeVersion": "8.0", "digest": "20b4bff9baa1c694ad98067dde834c56092f023b9664bec382c2e512232bd480" }, "swimmer_tone5": { "category": "activity", "moji": "🏊🏿", + "description": "swimmer tone 5", "unicodeVersion": "8.0", "digest": "0ff8eb57c2be8e80a1bc6ba75b8d9ffb9bd8d3be636150c4c03399ec1886f218" }, "symbols": { "category": "symbols", "moji": "🔣", + "description": "input symbol for symbols", "unicodeVersion": "6.0", "digest": "2a2a79816c4d0751a0d73586eec5e63b410653d3c85cc968906bf1fc03d89b94" }, "synagogue": { "category": "travel", "moji": "🕍", + "description": "synagogue", "unicodeVersion": "8.0", "digest": "98569cdd7c61528963b67b7891dfa46025c5e810cbb22ee18ddb3bd85de2da69" }, "syringe": { "category": "objects", "moji": "💉", + "description": "syringe", "unicodeVersion": "6.0", "digest": "e1538e645ccc571227c994b71b3d1be2c4d072d8bd9c944a42ff4a11c91a34a6" }, "taco": { "category": "food", "moji": "🌮", + "description": "taco", "unicodeVersion": "8.0", "digest": "e1e45aefdb7445faeae75c3831df6a3d6f2590fcdd48a20d847593c246df613b" }, "tada": { "category": "objects", "moji": "🎉", + "description": "party popper", "unicodeVersion": "6.0", "digest": "1d2e6cbb2a3244240bc70209715d2213d1efee2e370cccfbcc046c333ae2d650" }, "tanabata_tree": { "category": "nature", "moji": "🎋", + "description": "tanabata tree", "unicodeVersion": "6.0", "digest": "592f2907ffc1b914390e1a106c15120ff3607e99192158b94d237975647c5540" }, "tangerine": { "category": "food", "moji": "🍊", + "description": "tangerine", "unicodeVersion": "6.0", "digest": "40c9ddcde1b0bcfaeb466629a87825eb8c2037835720cbee5e2fda04be3c8d0a" }, "taurus": { "category": "symbols", "moji": "♉", + "description": "taurus", "unicodeVersion": "1.1", "digest": "21cf24cb6410ab6596e2df8b3e242cc07f9dbb247eabc00c590fe184b373d068" }, "taxi": { "category": "travel", "moji": "🚕", + "description": "taxi", "unicodeVersion": "6.0", "digest": "c546cc743831cfbf0c15452767cf2a4faf3775066797e997ae7c1fcbe4eca479" }, "tea": { "category": "food", "moji": "🍵", + "description": "teacup without handle", "unicodeVersion": "6.0", "digest": "00e3f1e389fa58c4fcd8c53ebbf83d25872f4315845ab1984b35410ae65553d9" }, "telephone": { "category": "objects", "moji": "☎", + "description": "black telephone", "unicodeVersion": "1.1", "digest": "3a53851e641f8ad938ce3597b1afca2ea63c9314ff81f62563b99937496a13d7" }, "telephone_receiver": { "category": "objects", "moji": "📞", + "description": "telephone receiver", "unicodeVersion": "6.0", "digest": "1614d67f3d8814b0d75f39d55f9149e4d28ef57b343498625e62fcfff8365046" }, "telescope": { "category": "objects", "moji": "🔭", + "description": "telescope", "unicodeVersion": "6.0", "digest": "4adf40387870276c4f59fb050d441023e8dac784365b6a8c0282fb519780b495" }, "ten": { "category": "symbols", "moji": "🔟", + "description": "keycap ten", "unicodeVersion": "6.0", "digest": "c7c9491021740d2c17edddb856f79579b0b943d8dc85a2f48dbaac84f35b8a40" }, "tennis": { "category": "activity", "moji": "🎾", + "description": "tennis racquet and ball", "unicodeVersion": "6.0", "digest": "dc1600b4d8dce3d26259eb0d1c6ab042566565e3c1f2c96112210f1550a716fd" }, "tent": { "category": "travel", "moji": "⛺", + "description": "tent", "unicodeVersion": "5.2", "digest": "30d9b17ac3219d4970ddf54d7c1a288b0ae50f7f3b82ed232c0b1b19ef585662" }, "thermometer": { "category": "objects", "moji": "🌡", + "description": "thermometer", "unicodeVersion": "7.0", "digest": "66616babbcaef256d7b652796c760e8e893cb950c073348a408fe70904f80f25" }, "thermometer_face": { "category": "people", "moji": "🤒", + "description": "face with thermometer", "unicodeVersion": "8.0", "digest": "ac2b5caddd128563711a9dcc7f690cf210f684d5e8b64b09c0431d6902437126" }, "thinking": { "category": "people", "moji": "🤔", + "description": "thinking face", "unicodeVersion": "8.0", "digest": "4f0b84e5ab8a650cafb166e93688f0e9b31b9ade22a91035261ac90490edb9d3" }, "third_place": { "category": "activity", "moji": "🥉", + "description": "third place medal", "unicodeVersion": "9.0", "digest": "27c9bcba44ad95bee30882cc0722e8b0a798206306655dd648e884447ed26808" }, "thought_balloon": { "category": "symbols", "moji": "💭", + "description": "thought balloon", "unicodeVersion": "6.0", "digest": "bf59624560c333561d636aedf2c8827089e275895cf434974daaabb3d5cea46e" }, "three": { "category": "symbols", "moji": "3️⃣", + "description": "keycap digit three", "unicodeVersion": "3.0", "digest": "d3f85828787799c769655c38a519cad0743ab799ab276c7606e6e6894cc442e6" }, "thumbsdown": { "category": "people", "moji": "👎", + "description": "thumbs down sign", "unicodeVersion": "6.0", "digest": "5954334e2dae5357312b3d629f10a496c728029e02216f8c8b887f9b51561c61" }, "thumbsdown_tone1": { "category": "people", "moji": "👎🏻", + "description": "thumbs down sign tone 1", "unicodeVersion": "8.0", "digest": "3c2853491473fd7ae2d1b5415a425cc390d26a8754446f8736c1360e4cb18ba3" }, "thumbsdown_tone2": { "category": "people", "moji": "👎🏼", + "description": "thumbs down sign tone 2", "unicodeVersion": "8.0", "digest": "4e0f8f86a06b69e423df8d93f41ec393f12800633acc82c4cb6dff64ca0d8507" }, "thumbsdown_tone3": { "category": "people", "moji": "👎🏽", + "description": "thumbs down sign tone 3", "unicodeVersion": "8.0", "digest": "e08fa35575f59978612d4330bbc35313eca9c4dfa04f4212626abc700819effe" }, "thumbsdown_tone4": { "category": "people", "moji": "👎🏾", + "description": "thumbs down sign tone 4", "unicodeVersion": "8.0", "digest": "7c6d118d20d5add8ca003e4a53e42685a1f9436b872ed10d79f67ad418fb2a44" }, "thumbsdown_tone5": { "category": "people", "moji": "👎🏿", + "description": "thumbs down sign tone 5", "unicodeVersion": "8.0", "digest": "8697c4a4ee4d6669dc2d47aa97699c42012ca59b80818ad6845878b37b4a9c58" }, "thumbsup": { "category": "people", "moji": "👍", + "description": "thumbs up sign", "unicodeVersion": "6.0", "digest": "59ec2457ab33e8897261d01a495f6cf5c668d0004807dc541c3b1be5294b1e61" }, "thumbsup_tone1": { "category": "people", "moji": "👍🏻", + "description": "thumbs up sign tone 1", "unicodeVersion": "8.0", "digest": "f57e6c525e8830779ea5026590eec3ca10869dc438a0c779734b617d04f28d21" }, "thumbsup_tone2": { "category": "people", "moji": "👍🏼", + "description": "thumbs up sign tone 2", "unicodeVersion": "8.0", "digest": "980eeeb1d8f5d79dae35c7ff81a576e980aa13a440d07b10e32e98ed34cbf7f1" }, "thumbsup_tone3": { "category": "people", "moji": "👍🏽", + "description": "thumbs up sign tone 3", "unicodeVersion": "8.0", "digest": "b3881060569e56e1dd75ca7960feab0e58ae51f440458781948d65d461116b4e" }, "thumbsup_tone4": { "category": "people", "moji": "👍🏾", + "description": "thumbs up sign tone 4", "unicodeVersion": "8.0", "digest": "86fbe2c95414bce5e38fb5c33da31305d7942fca2c9c79168dcffdbd895e9ad6" }, "thumbsup_tone5": { "category": "people", "moji": "👍🏿", + "description": "thumbs up sign tone 5", "unicodeVersion": "8.0", "digest": "49fa63ff725c746a18649df16c8fab69bad88bbb564884df79d1d15f553b7343" }, "thunder_cloud_rain": { "category": "nature", "moji": "⛈", + "description": "thunder cloud and rain", "unicodeVersion": "5.2", "digest": "dacc20b4f6b68e5834aa1b8391afa5e83b5e6eb28e2d2174d3a68186a770506d" }, "ticket": { "category": "activity", "moji": "🎫", + "description": "ticket", "unicodeVersion": "6.0", "digest": "b4326fe7761940216e6c76ee2928110a6b37bf913da9d694e96557e7c7c10420" }, "tickets": { "category": "activity", "moji": "🎟", + "description": "admission tickets", "unicodeVersion": "7.0", "digest": "fb73358c3697c04fcfde6a1e705b1c3b47635b93b9cadfe31d5657566c7d190a" }, "tiger": { "category": "nature", "moji": "🐯", + "description": "tiger face", "unicodeVersion": "6.0", "digest": "e139531e6c930bc46242dc0ed274661229de026b5419d8ea8f99fdb0f8a719ab" }, "tiger2": { "category": "nature", "moji": "🐅", + "description": "tiger", "unicodeVersion": "6.0", "digest": "f930cc8714198310d9b0edca6baff243ac5a3320f75fadb56fa5acc6fe34ff24" }, "timer": { "category": "objects", "moji": "⏲", + "description": "timer clock", "unicodeVersion": "6.0", "digest": "69b33f219523d89d81cbbc070ad7e528711e4b34e124a50acb12a0280a34d0b0" }, "tired_face": { "category": "people", "moji": "😫", + "description": "tired face", "unicodeVersion": "6.0", "digest": "775739bc9324517e614878ca0960d793df97775feeb62b14dbfb311a42a21802" }, "tm": { "category": "symbols", "moji": "™", + "description": "trade mark sign", "unicodeVersion": "1.1", "digest": "7d9fafdb72d91860478fc185719f289f359eab2c368a132cb936a269e2ab6a24" }, "toilet": { "category": "objects", "moji": "🚽", + "description": "toilet", "unicodeVersion": "6.0", "digest": "0d1b0dd0078f51104e8632a0726e1b3f075561a1ffa8a2546602de15798415d0" }, "tokyo_tower": { "category": "travel", "moji": "🗼", + "description": "tokyo tower", "unicodeVersion": "6.0", "digest": "73eaf6fd59d16396673afef620c6d928857d5cf616e95a40eaf2861686e0956a" }, "tomato": { "category": "food", "moji": "🍅", + "description": "tomato", "unicodeVersion": "6.0", "digest": "d092d8ad381d542e59b6a82b4f1ef0d10fc1ed48460952375c6c5c6258cea111" }, "tone1": { "category": "modifier", "moji": "🏻", + "description": "emoji modifier Fitzpatrick type-1-2", "unicodeVersion": "8.0", "digest": "5c62003a098b774c068be45d658db3c0dd38483c0871f7c8ae293bc1222c4f0c" }, "tone2": { "category": "modifier", "moji": "🏼", + "description": "emoji modifier Fitzpatrick type-3", "unicodeVersion": "8.0", "digest": "3c636ecbc4e58c7a360f2338daaf44e7da598fd07e0ba1514bb5c0f83fc8819f" }, "tone3": { "category": "modifier", "moji": "🏽", + "description": "emoji modifier Fitzpatrick type-4", "unicodeVersion": "8.0", "digest": "398a1e5441b64c9c2d033bbc01d7a8d90b4db30ea9f30e28f0a9120c72a48df8" }, "tone4": { "category": "modifier", "moji": "🏾", + "description": "emoji modifier Fitzpatrick type-5", "unicodeVersion": "8.0", "digest": "ff4a12195aeb7494c785b81266efad8cd60c8022c407a0fc032a02e8b83216b3" }, "tone5": { "category": "modifier", "moji": "🏿", + "description": "emoji modifier Fitzpatrick type-6", "unicodeVersion": "8.0", "digest": "9e9f0125b5d57011b7456c84719e6be6cf71d06c1b198081d0937c0979164a81" }, "tongue": { "category": "people", "moji": "👅", + "description": "tongue", "unicodeVersion": "6.0", "digest": "286e9d2583c371431d6fc979dd4ab48981676da26baada51a846657a3654c19b" }, "tools": { "category": "objects", "moji": "🛠", + "description": "hammer and wrench", "unicodeVersion": "7.0", "digest": "bf08d60dedc06de73d04dab05703bb8ad81989c72b5035d1a07821e51096f158" }, "top": { "category": "symbols", "moji": "🔝", + "description": "top with upwards arrow above", "unicodeVersion": "6.0", "digest": "c9a9f25b17db014e76b6be54aa07ef89bb18f8adb41b3199d180a559ff1d9ea5" }, "tophat": { "category": "people", "moji": "🎩", + "description": "top hat", "unicodeVersion": "6.0", "digest": "43a45dfb5d6b57a63a0491f4e3ec780774c0301b53ed39a303a0bd803d16ed71" }, "track_next": { "category": "symbols", "moji": "⏭", + "description": "black right-pointing double triangle with vertical bar", "unicodeVersion": "6.0", "digest": "88592ef6c720a32aeb752322fb4c794bf5110a72408e21e898630452115c731c" }, "track_previous": { "category": "symbols", "moji": "⏮", + "description": "black left-pointing double triangle with vertical bar", "unicodeVersion": "6.0", "digest": "98c1b3d643768d94857fb762f6d26cfb87282b449a67792242e8b7068643ac87" }, "trackball": { "category": "objects", "moji": "🖲", + "description": "trackball", "unicodeVersion": "7.0", "digest": "32a819a3129429f797ad434d0c40e263dc236808e34878c599ed2304b43702f5" }, "tractor": { "category": "travel", "moji": "🚜", + "description": "tractor", "unicodeVersion": "6.0", "digest": "5e4686290f1a4c9953ae208340b7d276f25b3b2197a43e52469aeb6450e93997" }, "traffic_light": { "category": "travel", "moji": "🚥", + "description": "horizontal traffic light", "unicodeVersion": "6.0", "digest": "d96aacade33d1ad3e0414f8a920513010f36eb7e5889774251c1d91148917ead" }, "train": { "category": "travel", "moji": "🚋", + "description": "Tram Car", "unicodeVersion": "6.0", "digest": "7423d17e131df7aadaa350b5d39dcbce3b28de331ff8b6703a3b2d0093963f4b" }, "train2": { "category": "travel", "moji": "🚆", + "description": "train", "unicodeVersion": "6.0", "digest": "06e65d549e771632f3c64287a38ba67236f9800ccb6a23c3b592bc010e24e122" }, "tram": { "category": "travel", "moji": "🚊", + "description": "tram", "unicodeVersion": "6.0", "digest": "21a7699f1a94f06dcb4d1e896448b98a4205f8efe902a8ac169a5005d11ab100" }, "triangular_flag_on_post": { "category": "objects", "moji": "🚩", + "description": "triangular flag on post", "unicodeVersion": "6.0", "digest": "1f5ce3828a42f5b1717bac1521d0502cf7081ad9f15e8ed292c1a65f0d1386da" }, "triangular_ruler": { "category": "objects", "moji": "📐", + "description": "triangular ruler", "unicodeVersion": "6.0", "digest": "a0367dcf663ec934f1fc7c88bfaccc02b229a896f60930a66bb02241c933e501" }, "trident": { "category": "symbols", "moji": "🔱", + "description": "trident emblem", "unicodeVersion": "6.0", "digest": "ee45920845d3b35c2e45b934cf30ce97bfe2f24c5d72ef1ac6e0842e52b50fc1" }, "triumph": { "category": "people", "moji": "😤", + "description": "face with look of triumph", "unicodeVersion": "6.0", "digest": "4aa44b8e1682c1269624a359f4b0bf613553683b883d947561ab169d7f85da0f" }, "trolleybus": { "category": "travel", "moji": "🚎", + "description": "trolleybus", "unicodeVersion": "6.0", "digest": "f610b4fd1123f06778a8e3bb8f738d5b0079aeb0b0926b6a63268c0dd0ee03ed" }, "trophy": { "category": "activity", "moji": "🏆", + "description": "trophy", "unicodeVersion": "6.0", "digest": "50cfbedac18bf0fa5dec727643e15ec47f64068944b536e97518ee3be4f08006" }, "tropical_drink": { "category": "food", "moji": "🍹", + "description": "tropical drink", "unicodeVersion": "6.0", "digest": "54144fce60d650f426b1edf09e47c70b2762222398c1fe40231881f074603a69" }, "tropical_fish": { "category": "nature", "moji": "🐠", + "description": "tropical fish", "unicodeVersion": "6.0", "digest": "fd92100aaa9328da35e6090388824921b9726b474d1432a926d2cf9c45ad6528" }, "truck": { "category": "travel", "moji": "🚚", + "description": "delivery truck", "unicodeVersion": "6.0", "digest": "0d1571e58e900abc453df0ff683fe7acb5906ecbdd52ab35b7101074359faf18" }, "trumpet": { "category": "activity", "moji": "🎺", + "description": "trumpet", "unicodeVersion": "6.0", "digest": "cea3614c309f5573f328f4603120dbe930016a35f0dfa400b0d968fe9fff2d55" }, "tulip": { "category": "nature", "moji": "🌷", + "description": "tulip", "unicodeVersion": "6.0", "digest": "e744e8dbbdc6b126bd5b15aad56b524191de5a604189f4ab6d96730dfef4d086" }, "tumbler_glass": { "category": "food", "moji": "🥃", + "description": "tumbler glass", "unicodeVersion": "9.0", "digest": "7a38658274b9ff28836725a1dbfad49b8fa3af5ec8385e629db6bfdc7d93907a" }, "turkey": { "category": "nature", "moji": "🦃", + "description": "turkey", "unicodeVersion": "8.0", "digest": "bf5daef15716b66636a5fdb6d059420521443c0603e2d56bd7c99c791a7285f4" }, "turtle": { "category": "nature", "moji": "🐢", + "description": "turtle", "unicodeVersion": "6.0", "digest": "588c35fb42c9502a908e9805517d4cc8c4ba4e74c9beed4035779fea1efe14f8" }, "tv": { "category": "objects", "moji": "📺", + "description": "television", "unicodeVersion": "6.0", "digest": "1279f3f3955a58dbbf74e248fc914b0bdba9c4c6b6a5176e9d12bf2750ecfeb4" }, "twisted_rightwards_arrows": { "category": "symbols", "moji": "🔀", + "description": "twisted rightwards arrows", "unicodeVersion": "6.0", "digest": "fed07eebc2cf0d977ca0826bbd80defafbbcf118508444148f47b58949ebe27c" }, "two": { "category": "symbols", "moji": "2️⃣", + "description": "keycap digit two", "unicodeVersion": "3.0", "digest": "b346f51f6523b02ebcbd753256804e2f9cc1574c96aa634362bf9401dac2c661" }, "two_hearts": { "category": "symbols", "moji": "💕", + "description": "two hearts", "unicodeVersion": "6.0", "digest": "6ded120a59aed790b441ec8fbbdea6f5cbfb4fa48e9e4b224cc29c9fde2d2e4c" }, "two_men_holding_hands": { "category": "people", "moji": "👬", + "description": "two men holding hands", "unicodeVersion": "6.0", "digest": "bfcf9e20a67d00262cdf6e85f1acd545dda91f2e370d68bfd41ce02f232a2987" }, "two_women_holding_hands": { "category": "people", "moji": "👭", + "description": "two women holding hands", "unicodeVersion": "6.0", "digest": "9d9d2b37a7f8e16fde1468dd8b5645003ea81ae4bf8bcf68471e2381845dd0dd" }, "u5272": { "category": "symbols", "moji": "🈹", + "description": "squared cjk unified ideograph-5272", "unicodeVersion": "6.0", "digest": "01e6cb8f74ea3c19fdade59c2d13d158b90dc6b4b293421b2014b7478bf20870" }, "u5408": { "category": "symbols", "moji": "🈴", + "description": "squared cjk unified ideograph-5408", "unicodeVersion": "6.0", "digest": "084cdbd5436670ea4dc22010e269c1ab7b0432897b8675301e69120374bcdd14" }, "u55b6": { "category": "symbols", "moji": "🈺", + "description": "squared cjk unified ideograph-55b6", "unicodeVersion": "6.0", "digest": "c1017023d20d4aae78d59342dd3bfc5282716ea0601d9a8c2476335cbf7a2e12" }, "u6307": { "category": "symbols", "moji": "🈯", + "description": "squared cjk unified ideograph-6307", "unicodeVersion": "5.2", "digest": "f459b092b974f459db1fb9cc13617a448b2e4f2b4dc46cc316d8c46af6e7d8bd" }, "u6708": { "category": "symbols", "moji": "🈷", + "description": "squared cjk unified ideograph-6708", "unicodeVersion": "6.0", "digest": "928815abf5b30f92efe5168de0c7e6cf8c17899a03e358ab42f42667e0a4a04c" }, "u6709": { "category": "symbols", "moji": "🈶", + "description": "squared cjk unified ideograph-6709", "unicodeVersion": "6.0", "digest": "f63a48ee06c892d24acec8b5634c021658d2ebde67a42d8faa86f27804a9f26d" }, "u6e80": { "category": "symbols", "moji": "🈵", + "description": "squared cjk unified ideograph-6e80", "unicodeVersion": "6.0", "digest": "489181d90a5e43068459530673a153e4af04fdad8514ec341ff7afbcfd366c3b" }, "u7121": { "category": "symbols", "moji": "🈚", + "description": "squared cjk unified ideograph-7121", "unicodeVersion": "5.2", "digest": "9c50fd2ba14221affd2dcd3746322c2137dd75458493f4d385b544eb5bd8d6cd" }, "u7533": { "category": "symbols", "moji": "🈸", + "description": "squared cjk unified ideograph-7533", "unicodeVersion": "6.0", "digest": "2b05819b380a2ea47cc5fde8fcce3d53922fd223d6f5bd83d696d44175b69f18" }, "u7981": { "category": "symbols", "moji": "🈲", + "description": "squared cjk unified ideograph-7981", "unicodeVersion": "6.0", "digest": "adbe12601b22972003ddebcb0bd1532b979aa9c78bfdc147511854b5014eabc0" }, "u7a7a": { "category": "symbols", "moji": "🈳", + "description": "squared cjk unified ideograph-7a7a", "unicodeVersion": "6.0", "digest": "b9ee0ec7bb0b86c3eb73d4dbbb91848c427bf356ae30a263b9b44bd9bd784482" }, "umbrella": { "category": "nature", "moji": "☔", + "description": "umbrella with rain drops", "unicodeVersion": "4.0", "digest": "0328a2f48b7df47905e2655460e524c0794ef12d3d7c32a049a10892d5662f77" }, "umbrella2": { "category": "nature", "moji": "☂", + "description": "umbrella", "unicodeVersion": "1.1", "digest": "2f6a58110dc590480a822a3ffa2b5bc86f295e0c994a4a632837d25d4cf9fc58" }, "unamused": { "category": "people", "moji": "😒", + "description": "unamused face", "unicodeVersion": "6.0", "digest": "0d597088e3e7880918d0166e5c69243b18fe64afa31685c39bfdbc71494aa132" }, "underage": { "category": "symbols", "moji": "🔞", + "description": "no one under eighteen symbol", "unicodeVersion": "6.0", "digest": "b6b194614ca714ac2b1c2c17b75fe5922c7fdadb3d1157ba89ab2a5d03494a67" }, "unicorn": { "category": "nature", "moji": "🦄", + "description": "unicorn face", "unicodeVersion": "8.0", "digest": "f71bb485a7c208e999dd45f2b36d7b7d517898c0627947926b05aa28603804ca" }, "unlock": { "category": "objects", "moji": "🔓", + "description": "open lock", "unicodeVersion": "6.0", "digest": "9554ef3a6a315938b873e77970d9b0212e61f13c6cc36e4f17f87acc930a9a53" }, "up": { "category": "symbols", "moji": "🆙", + "description": "squared up with exclamation mark", "unicodeVersion": "6.0", "digest": "ff2554ccf08c7208b38794c5fa3d9a93a46ff191a49401195d8f740846121906" }, "upside_down": { "category": "people", "moji": "🙃", + "description": "upside-down face", "unicodeVersion": "8.0", "digest": "5129121f0a28f5b334268c28565de26a5907559568deca11de6ec620b097dfe1" }, "urn": { "category": "objects", "moji": "⚱", + "description": "funeral urn", "unicodeVersion": "4.1", "digest": "9bebf589eed8dd361f6a03cd1b325078f2cd0e82270ef63a7dd1b6aee08cd1e6" }, "v": { "category": "people", "moji": "✌", + "description": "victory hand", "unicodeVersion": "1.1", "digest": "9825bf440df289a8edf8ede494e8c778dc63c95f967f4d7bbea3245cf4f558ec" }, "v_tone1": { "category": "people", "moji": "✌🏻", + "description": "victory hand tone 1", "unicodeVersion": "8.0", "digest": "76e358250d9ca519b60b8d7b6a32900700d784433dcc609e9442254a410f6e37" }, "v_tone2": { "category": "people", "moji": "✌🏼", + "description": "victory hand tone 2", "unicodeVersion": "8.0", "digest": "4081b674be8416136022523fa9f29ec70a0f7e3aa05ca13152606609f3fd003c" }, "v_tone3": { "category": "people", "moji": "✌🏽", + "description": "victory hand tone 3", "unicodeVersion": "8.0", "digest": "b6afb3a4c78384280610b953592d378241c75597a82aa6d16c86a993f8d8f3b0" }, "v_tone4": { "category": "people", "moji": "✌🏾", + "description": "victory hand tone 4", "unicodeVersion": "8.0", "digest": "7ddc3cdd0138da2c8d7f6d8257ffdb8801496043e8a2395f93b0663447ac7fce" }, "v_tone5": { "category": "people", "moji": "✌🏿", + "description": "victory hand tone 5", "unicodeVersion": "8.0", "digest": "a85dc5c589f0d1cf32f8bfa5c82e5c11c40b35439636914686a2f06f7359f539" }, "vertical_traffic_light": { "category": "travel", "moji": "🚦", + "description": "vertical traffic light", "unicodeVersion": "6.0", "digest": "8cfd49a8f96b15a8313ef855f2e234ea3fa58332e68896dea34760740de9f020" }, "vhs": { "category": "objects", "moji": "📼", + "description": "videocassette", "unicodeVersion": "6.0", "digest": "3fb1acaf25805cf86f8d40ee2c17cf25da587b7ca93b931167ab43fce041eee8" }, "vibration_mode": { "category": "symbols", "moji": "📳", + "description": "vibration mode", "unicodeVersion": "6.0", "digest": "c9a8899222f46fe51dd8cee3e59f77c48268f0b7cfae2bcb34a791213acb1755" }, "video_camera": { "category": "objects", "moji": "📹", + "description": "video camera", "unicodeVersion": "6.0", "digest": "62e56f26c286a7964ef1021f0f23fcb4b38cdcfb5b5af569b472340c412c619a" }, "video_game": { "category": "activity", "moji": "🎮", + "description": "video game", "unicodeVersion": "6.0", "digest": "2787e302aa9e6fd7e9dc382c9bc7f5fbf244ef4940e08a4f9e80d33324f3032e" }, "violin": { "category": "activity", "moji": "🎻", + "description": "violin", "unicodeVersion": "6.0", "digest": "1e69d531ce2b5d5bf1dd9470187dbbe76f479d14428834b6a9e2bf5296dc0ec9" }, "virgo": { "category": "symbols", "moji": "♍", + "description": "virgo", "unicodeVersion": "1.1", "digest": "0f75e9c228bc467fd0cec0f93f0e087c943bc5fb1d945fb0d4de53d07718388e" }, "volcano": { "category": "travel", "moji": "🌋", + "description": "volcano", "unicodeVersion": "6.0", "digest": "41c92ef88ca533df342a0ebe59d2b676873bfa944c3988495b8a96060a9b8e16" }, "volleyball": { "category": "activity", "moji": "🏐", + "description": "volleyball", "unicodeVersion": "8.0", "digest": "774a83357f7aee890b4d4383236f0a90946dbd7c86aaabadc5753dcc9b4c9d69" }, "vs": { "category": "symbols", "moji": "🆚", + "description": "squared vs", "unicodeVersion": "6.0", "digest": "ac943e4c737459c2e1adbac8b71d3fdaebb704dbaf5713012e7a77beb09db1ef" }, "vulcan": { "category": "people", "moji": "🖖", + "description": "raised hand with part between middle and ring fingers", "unicodeVersion": "7.0", "digest": "b4d409a0b019e7b06333cefd15ea46cb54aef5132d86e8ba361c1c3b911fe265" }, "vulcan_tone1": { "category": "people", "moji": "🖖🏻", + "description": "raised hand with part between middle and ring fingers tone 1", "unicodeVersion": "8.0", "digest": "cc6072c85031b5081995f98a57f09ab177168318f69a51f3acc63251760499a4" }, "vulcan_tone2": { "category": "people", "moji": "🖖🏼", + "description": "raised hand with part between middle and ring fingers tone 2", "unicodeVersion": "8.0", "digest": "858bd5a1ac91dc4d7735f57ba4dd69d39138aa6dac1c80cfc05de30a59a5bc33" }, "vulcan_tone3": { "category": "people", "moji": "🖖🏽", + "description": "raised hand with part between middle and ring fingers tone 3", "unicodeVersion": "8.0", "digest": "2f74b6f3eab2a75063591b66f1c7350af0d23153e1427af91de20c48a5f4a54a" }, "vulcan_tone4": { "category": "people", "moji": "🖖🏾", + "description": "raised hand with part between middle and ring fingers tone 4", "unicodeVersion": "8.0", "digest": "87cf8b87d3610f742857a9704b658462df32b4924d8f1ddba26f761e738c4e11" }, "vulcan_tone5": { "category": "people", "moji": "🖖🏿", + "description": "raised hand with part between middle and ring fingers tone 5", "unicodeVersion": "8.0", "digest": "11e9ff62f2385edeb477dbf66c63734536531def5771daf80b66a3425ac71493" }, "walking": { "category": "people", "moji": "🚶", + "description": "pedestrian", "unicodeVersion": "6.0", "digest": "ae77471fe1e8a734d11711cdb589f64347c35d6ee2fc10f6db16ac550c0557fa" }, "walking_tone1": { "category": "people", "moji": "🚶🏻", + "description": "pedestrian tone 1", "unicodeVersion": "8.0", "digest": "3de871c234e1340ccf95338df7babd94d175cfcb17a57b5a74d950e0a31f03b1" }, "walking_tone2": { "category": "people", "moji": "🚶🏼", + "description": "pedestrian tone 2", "unicodeVersion": "8.0", "digest": "620eb7bfb753a331a5822b02bdaf08d8dde7b573efd210287a3d3dfdd84a40b9" }, "walking_tone3": { "category": "people", "moji": "🚶🏽", + "description": "pedestrian tone 3", "unicodeVersion": "8.0", "digest": "ff39545acc2256006128f8c186433c28052b8c9aaec46fe06f25cff02c71f6b8" }, "walking_tone4": { "category": "people", "moji": "🚶🏾", + "description": "pedestrian tone 4", "unicodeVersion": "8.0", "digest": "a9499d142392977a9b9e54fb957952359e9bdffce7ec2f1e8320523d185fb066" }, "walking_tone5": { "category": "people", "moji": "🚶🏿", + "description": "pedestrian tone 5", "unicodeVersion": "8.0", "digest": "b47a4c48ce40298f842f454fc1abccae70f69725d73ee2c80e4018f4c4065d7d" }, "waning_crescent_moon": { "category": "nature", "moji": "🌘", + "description": "waning crescent moon symbol", "unicodeVersion": "6.0", "digest": "2ec7896eefcf821e0ea013556a17af59e997503662c07f080d0a84ab13ef4cf1" }, "waning_gibbous_moon": { "category": "nature", "moji": "🌖", + "description": "waning gibbous moon symbol", "unicodeVersion": "6.0", "digest": "ce2f5aca8fccdacaaf174d10da4e493e853e4608cc4d159aa3081d108a8b58d5" }, "warning": { "category": "symbols", "moji": "⚠", + "description": "warning sign", "unicodeVersion": "4.0", "digest": "745f1d203958f42bf37ecb5909cd0819934e300308ba0ff20964c8c203092f90" }, "wastebasket": { "category": "objects", "moji": "🗑", + "description": "wastebasket", "unicodeVersion": "7.0", "digest": "221a1b6d9975051038d9d97e18a16556cdf4254a6bca4c29bf1c51f306c79f2a" }, "watch": { "category": "objects", "moji": "⌚", + "description": "watch", "unicodeVersion": "1.1", "digest": "acc0c96751404a789b3085f10425cf34f942185215df459515d2439cde3efc6b" }, "water_buffalo": { "category": "nature", "moji": "🐃", + "description": "water buffalo", "unicodeVersion": "6.0", "digest": "ba6a840d4f57f8f9f3e9f29b8a030faf02a3a3d912e3e31b067616b2ac48a3d1" }, "water_polo": { "category": "activity", "moji": "🤽", + "description": "water polo", "unicodeVersion": "9.0", "digest": "fc77e1d2a84a9f4cf0cf19c1ea10cf137cf0940b9103a523121eda87677ad148" }, "water_polo_tone1": { "category": "activity", "moji": "🤽🏻", + "description": "water polo tone 1", "unicodeVersion": "9.0", "digest": "3be28384edd29ada8109f07720d601a9d5866ed63e6234efe9ee1a194ed5d0c5" }, "water_polo_tone2": { "category": "activity", "moji": "🤽🏼", + "description": "water polo tone 2", "unicodeVersion": "9.0", "digest": "afcd3f28c6719f869ca79a6fd1ccade2ea976ade844fbc1081fc72865bcb652f" }, "water_polo_tone3": { "category": "activity", "moji": "🤽🏽", + "description": "water polo tone 3", "unicodeVersion": "9.0", "digest": "d19481c9b82d9413e99c2652e020fd763f2b54408dedaffec8dfe80973ded407" }, "water_polo_tone4": { "category": "activity", "moji": "🤽🏾", + "description": "water polo tone 4", "unicodeVersion": "9.0", "digest": "375972d882b627e8d525e632e58b30346fc3e01858d7d08d62a9d3bf8132bbc7" }, "water_polo_tone5": { "category": "activity", "moji": "🤽🏿", + "description": "water polo tone 5", "unicodeVersion": "9.0", "digest": "a8e1ced1c5382a8147a1d1801a133cada9a0e52e41de6272e56c3c1f426f6048" }, "watermelon": { "category": "food", "moji": "🍉", + "description": "watermelon", "unicodeVersion": "6.0", "digest": "42a3821d2e4dd595c93f5db7a5c70b7af486b8f0ddd3b9d26bc4e743a88e699a" }, "wave": { "category": "people", "moji": "👋", + "description": "waving hand sign", "unicodeVersion": "6.0", "digest": "cddbd764d471604446cbaca91f77f6c4119d1cfc2c856732ca0eaac4593cb736" }, "wave_tone1": { "category": "people", "moji": "👋🏻", + "description": "waving hand sign tone 1", "unicodeVersion": "8.0", "digest": "cf40797437ddf68ec0275f337e6aac4bed81e28da7636d56c9f817ddf8e2b30a" }, "wave_tone2": { "category": "people", "moji": "👋🏼", + "description": "waving hand sign tone 2", "unicodeVersion": "8.0", "digest": "12c8a3e82c03ee35a734c642be482ba2d9d5948dacf91ec1fda243316dd4a0d0" }, "wave_tone3": { "category": "people", "moji": "👋🏽", + "description": "waving hand sign tone 3", "unicodeVersion": "8.0", "digest": "ebcaef43e21b475f76de811d4f4d1a67d9393973b57b03876e02164345a2ba4a" }, "wave_tone4": { "category": "people", "moji": "👋🏾", + "description": "waving hand sign tone 4", "unicodeVersion": "8.0", "digest": "7df7b70cf76766836ba146c3d91b6104930c384450cf2688426e60c1c06a1fc8" }, "wave_tone5": { "category": "people", "moji": "👋🏿", + "description": "waving hand sign tone 5", "unicodeVersion": "8.0", "digest": "8dfdba6aeff5d7dfd807467d431a137547726b34d021f1a5a0b74e155d270ea7" }, "wavy_dash": { "category": "symbols", "moji": "〰", + "description": "wavy dash", "unicodeVersion": "1.1", "digest": "7b1968474f01d12fd09a1f2572282927138d9e9d6a3642de4bf68af80a8c3738" }, "waxing_crescent_moon": { "category": "nature", "moji": "🌒", + "description": "waxing crescent moon symbol", "unicodeVersion": "6.0", "digest": "852d7e55a19074d061fa3aa80d6b1e7e87a9280bdf44d94bbdbbe6d59178b1be" }, "waxing_gibbous_moon": { "category": "nature", "moji": "🌔", + "description": "waxing gibbous moon symbol", "unicodeVersion": "6.0", "digest": "a3a1c7cc72521a3f74929789a90e1c35d81ac86e21225c9f844d718d8940e3b3" }, "wc": { "category": "symbols", "moji": "🚾", + "description": "water closet", "unicodeVersion": "6.0", "digest": "4b95d54e0b53e4b705277917653503b32d6a143c2eaf6c547bc8e01c2dc23659" }, "weary": { "category": "people", "moji": "😩", + "description": "weary face", "unicodeVersion": "6.0", "digest": "3528f85540996cd5b562efe5421c495fc1bb414dc797bc20062783ae1b730847" }, "wedding": { "category": "travel", "moji": "💒", + "description": "wedding", "unicodeVersion": "6.0", "digest": "980f3522cc4c19c3096e668032ea2cd19e7900cdc4b73bbb1c9b4c4d28dc78af" }, "whale": { "category": "nature", "moji": "🐳", + "description": "spouting whale", "unicodeVersion": "6.0", "digest": "6368fe4bc4a7f68aa2bd5386686a5f1b159feacbec16d59515f2b6e5d01adfbd" }, "whale2": { "category": "nature", "moji": "🐋", + "description": "whale", "unicodeVersion": "6.0", "digest": "ccd3edf88167965f2abc18631ffb80e2532f728da35bc0c11144376685da18e8" }, "wheel_of_dharma": { "category": "symbols", "moji": "☸", + "description": "wheel of dharma", "unicodeVersion": "1.1", "digest": "4a0a13fcd507b9621686c8090bf340aa8770c064e0e3eb576fbae1229000d6da" }, "wheelchair": { "category": "symbols", "moji": "♿", + "description": "wheelchair symbol", "unicodeVersion": "4.1", "digest": "f5250f2b4b5b4ffe6a6f77d30865c3f5d7173fc91aee547869589b2a96da91c8" }, "white_check_mark": { "category": "symbols", "moji": "✅", + "description": "white heavy check mark", "unicodeVersion": "6.0", "digest": "45eb17bde6e503f22c8579d6e4d507ad6557a15f9eaad14aa716ec9ba1540876" }, "white_circle": { "category": "symbols", "moji": "⚪", + "description": "medium white circle", "unicodeVersion": "4.1", "digest": "2e7323fa4d1e3929e529d49210a0b82a043eae4f7c95128ec86b98c46fdb0e7c" }, "white_flower": { "category": "symbols", "moji": "💮", + "description": "white flower", "unicodeVersion": "6.0", "digest": "ace093b310eeefdecf4a4bdaf4fbcbb568457b0191ac80778a466ac5f3f4025a" }, "white_large_square": { "category": "symbols", "moji": "⬜", + "description": "white large square", "unicodeVersion": "5.1", "digest": "0db6957ee9ff7325b534b730fc05345a63d4ed9060f0f816807d0dcf004baa3e" }, "white_medium_small_square": { "category": "symbols", "moji": "◽", + "description": "white medium small square", "unicodeVersion": "3.2", "digest": "d79689981a7b38211c60a025a81e44fd39ac6ea4062e227cae3aab8f51572cd4" }, "white_medium_square": { "category": "symbols", "moji": "◻", + "description": "white medium square", "unicodeVersion": "3.2", "digest": "6c4ce26d3f69667219f29ea18b04f3e79373024426275f25936e09a683e9a4fc" }, "white_small_square": { "category": "symbols", "moji": "▫", + "description": "white small square", "unicodeVersion": "1.1", "digest": "ae0d35a6bbba4592b89b2f0f1f2d183efb2f93cf2a2136c0c195aab72f0bb1c8" }, "white_square_button": { "category": "symbols", "moji": "🔳", + "description": "white square button", "unicodeVersion": "6.0", "digest": "797f3d9e44e88e940ffc118e52d0f709eec2ef14b13bdf873ad4b0c96cc0b042" }, "white_sun_cloud": { "category": "nature", "moji": "🌥", + "description": "white sun behind cloud", "unicodeVersion": "7.0", "digest": "0e714038bb0a5b091dd4ad8829c5c72dece493e09da6d56ceadcd0b68e1c0fd5" }, "white_sun_rain_cloud": { "category": "nature", "moji": "🌦", + "description": "white sun behind cloud with rain", "unicodeVersion": "7.0", "digest": "82fb2a91d43c7c511afed216e12f98e32aef4475e7f3c7ccc0f39732d2f7d5e5" }, "white_sun_small_cloud": { "category": "nature", "moji": "🌤", + "description": "white sun with small cloud", "unicodeVersion": "7.0", "digest": "0a6164cdadf2413555b7ef47b95f823f5a010f36d2dacfb1a38335a0f59e9601" }, "wilted_rose": { "category": "nature", "moji": "🥀", + "description": "wilted flower", "unicodeVersion": "9.0", "digest": "2c9e01ab9a61d057c71478b09ba7d82ae08f4a5a1c2212b7ad562b74f616677f" }, "wind_blowing_face": { "category": "nature", "moji": "🌬", + "description": "wind blowing face", "unicodeVersion": "7.0", "digest": "e4f63149cbc8829118571f6a93487b96d26665fc15d17d578cca4e5c752cd54f" }, "wind_chime": { "category": "objects", "moji": "🎐", + "description": "wind chime", "unicodeVersion": "6.0", "digest": "1b1b212fbd74a9edc62aee7ffab9bcf91d3a9f69bffb2be4b7fd527914c14ced" }, "wine_glass": { "category": "food", "moji": "🍷", + "description": "wine glass", "unicodeVersion": "6.0", "digest": "d99107d6809386bc5e219aa58ee4930d27b7c3a6d2b10deb9f523df369f766d1" }, "wink": { "category": "people", "moji": "😉", + "description": "winking face", "unicodeVersion": "6.0", "digest": "56e29994a47335a901d0c98fa141d26faae8f647a860517bd3615fa980921885" }, "wolf": { "category": "nature", "moji": "🐺", + "description": "wolf face", "unicodeVersion": "6.0", "digest": "4a983f5ec8ec0872fcde7890e17605b1229064e5e194b6fca1c4259068d1caed" }, "woman": { "category": "people", "moji": "👩", + "description": "woman", "unicodeVersion": "6.0", "digest": "a06a22a48eeb3aeb885321358fe234e97797ed33be17f52d232ce2830cfbcd97" }, "woman_tone1": { "category": "people", "moji": "👩🏻", + "description": "woman tone 1", "unicodeVersion": "8.0", "digest": "c2e4b135c1dac6a0b002569a6ccd9d098f6cb18481c68b5d9115e11241a0978d" }, "woman_tone2": { "category": "people", "moji": "👩🏼", + "description": "woman tone 2", "unicodeVersion": "8.0", "digest": "4848e650051214a53c4cd9f6d3d94158f77f65ecb34f891789de34ee0a713006" }, "woman_tone3": { "category": "people", "moji": "👩🏽", + "description": "woman tone 3", "unicodeVersion": "8.0", "digest": "b6f751ad47da019cdfb9d6d78f9610adb92120abf204c30df79a9150b57dbdee" }, "woman_tone4": { "category": "people", "moji": "👩🏾", + "description": "woman tone 4", "unicodeVersion": "8.0", "digest": "fd27d3a669dc34313fbfe518df7dc2ded3ade5dde695f8d773afe87bf8a8b0d4" }, "woman_tone5": { "category": "people", "moji": "👩🏿", + "description": "woman tone 5", "unicodeVersion": "8.0", "digest": "9ae9b14dfff40fa60a565d89479727feeba4fd6ffea9acb353a81b14aba751d4" }, "womans_clothes": { "category": "people", "moji": "👚", + "description": "womans clothes", "unicodeVersion": "6.0", "digest": "d12a27810780fe5cd8118ed4587e0c4e70dbe9bcd014c6866fe6a8c9c7c55698" }, "womans_hat": { "category": "people", "moji": "👒", + "description": "womans hat", "unicodeVersion": "6.0", "digest": "52a0255b3483085bd125d39b74516ab6a81003964f44995c2fac821e7ff93086" }, "womens": { "category": "symbols", "moji": "🚺", + "description": "womens symbol", "unicodeVersion": "6.0", "digest": "7e38964006f8b28dfa2b3e9b2b16553bb50c18a63455f556b0bff35ee172137e" }, "worried": { "category": "people", "moji": "😟", + "description": "worried face", "unicodeVersion": "6.1", "digest": "5a073985e1344bc34201ef94a491f7f2b946f5828c9fdbc57eeb2dcd87ac3a6b" }, "wrench": { "category": "objects", "moji": "🔧", + "description": "wrench", "unicodeVersion": "6.0", "digest": "81aae53bc892035b905bf3ec5b442a8ecc95027c5fa9eb51b7c3e7d8fad3f3f4" }, "wrestlers": { "category": "activity", "moji": "🤼", + "description": "wrestlers", "unicodeVersion": "9.0", "digest": "9be983f3f9438f3ab8f6b643a958371d1e710c6d78e728f3465141811f05c2d5" }, "wrestlers_tone1": { "category": "activity", "moji": "🤼🏻", + "description": "wrestlers tone 1", "unicodeVersion": "9.0", "digest": "60461f83bfc93ce59dd027eab4782b7f206a7b142719fa72f301e047dc83a5d9" }, "wrestlers_tone2": { "category": "activity", "moji": "🤼🏼", + "description": "wrestlers tone 2", "unicodeVersion": "9.0", "digest": "67ad93c86e6c58d552c18e7a0105cc81fd9bb0474da51f788eba2e4c14b4a636" }, "wrestlers_tone3": { "category": "activity", "moji": "🤼🏽", + "description": "wrestlers tone 3", "unicodeVersion": "9.0", "digest": "6bfd06c4435cabf2def153912040e05bf8db424fa383148ddda6d0ce8a8a3349" }, "wrestlers_tone4": { "category": "activity", "moji": "🤼🏾", + "description": "wrestlers tone 4", "unicodeVersion": "9.0", "digest": "597312678834c4d288c238482879856d5eba4620deb1eaef495f428e2ba5f2a5" }, "wrestlers_tone5": { "category": "activity", "moji": "🤼🏿", + "description": "wrestlers tone 5", "unicodeVersion": "9.0", "digest": "d6aebdf1e44fd825b9a5b3716aefbc53f4b4dbb73cb2a628c0f2994ebfd34614" }, "writing_hand": { "category": "people", "moji": "✍", + "description": "writing hand", "unicodeVersion": "1.1", "digest": "110517ae4da5587e8b0662881658e27da4120bfacec54734fd6657831d4d782f" }, "writing_hand_tone1": { "category": "people", "moji": "✍🏻", + "description": "writing hand tone 1", "unicodeVersion": "8.0", "digest": "2c7e2108e1990490b681343c1b01b4183d4f18fbdef792f113b2f87595e0dad0" }, "writing_hand_tone2": { "category": "people", "moji": "✍🏼", + "description": "writing hand tone 2", "unicodeVersion": "8.0", "digest": "87ec8d44f472d301adbcbd50d8c852b609e46584057f59cc1527401db363c1bf" }, "writing_hand_tone3": { "category": "people", "moji": "✍🏽", + "description": "writing hand tone 3", "unicodeVersion": "8.0", "digest": "4a48ddef91f7264e8fa9cca223554db22b3a2e3153e94b88d146644ea6dd661e" }, "writing_hand_tone4": { "category": "people", "moji": "✍🏾", + "description": "writing hand tone 4", "unicodeVersion": "8.0", "digest": "e5254564a1f91e42ee59f359d8cd26f52abdc04dca8f3b37cb2f140cb7f71390" }, "writing_hand_tone5": { "category": "people", "moji": "✍🏿", + "description": "writing hand tone 5", "unicodeVersion": "8.0", "digest": "61299bf86d83d323ca3e6052c535ae66c6f7b3d9866a37db0464223b8bc28523" }, "x": { "category": "symbols", "moji": "❌", + "description": "cross mark", "unicodeVersion": "6.0", "digest": "3e5a7918e31ddefdf1ce73972365e2f0bfd2917d6a450c1a278c108349c9425d" }, "yellow_heart": { "category": "symbols", "moji": "💛", + "description": "yellow heart", "unicodeVersion": "6.0", "digest": "a1098f2f04c29754cc9974324508386787d4d803b57cf691d42de414cb2679d6" }, "yen": { "category": "objects", "moji": "💴", + "description": "banknote with yen sign", "unicodeVersion": "6.0", "digest": "944daaeb3f6369c807c0e63b106cee1360040f7800a70c0d942a992f25a55da7" }, "yin_yang": { "category": "symbols", "moji": "☯", + "description": "yin yang", "unicodeVersion": "1.1", "digest": "5ee8d13dacf41306a09237bfcff6abeef110331b40eb7d6e80600628c1327545" }, "yum": { "category": "people", "moji": "😋", + "description": "face savouring delicious food", "unicodeVersion": "6.0", "digest": "31a89088c21bd7a74a3a26d731a907d1bc49436300a9f9c55248703cf7ef44c7" }, "zap": { "category": "nature", "moji": "⚡", + "description": "high voltage sign", "unicodeVersion": "4.0", "digest": "9f8144ae6f866129aea41bbf694b0c858ef9352a139969e57cd8db73385f52c3" }, "zero": { "category": "symbols", "moji": "0️⃣", + "description": "keycap digit zero", "unicodeVersion": "3.0", "digest": "1b27b5c904defadbdd28ace67a6be5c277ff043297db7cd9f672bbf84e37fa1a" }, "zipper_mouth": { "category": "people", "moji": "🤐", + "description": "zipper-mouth face", "unicodeVersion": "8.0", "digest": "81bee5aa1202dfd5a4c7badb71ec0e44b8f75c2cbef94e6fd35c593d8770ae43" }, "zzz": { "category": "people", "moji": "💤", + "description": "sleeping symbol", "unicodeVersion": "6.0", "digest": "b3313d0c44a59fa9d4ce9f7eb4d07ff71dfc8bb01798154250f27cdcf3c693b5" } diff --git a/lib/api/api.rb b/lib/api/api.rb index 1bf20f76ad6..52cd7cbe3db 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -6,6 +6,7 @@ module API version 'v3', using: :path do helpers ::API::V3::Helpers + helpers ::API::Helpers::CommonHelpers mount ::API::V3::AwardEmoji mount ::API::V3::Boards @@ -44,6 +45,9 @@ module API end before { allow_access_with_scope :api } + before { Gitlab::I18n.set_locale(current_user) } + + after { Gitlab::I18n.reset_locale } rescue_from Gitlab::Access::AccessDeniedError do rack_response({ 'message' => '403 Forbidden' }.to_json, 403) @@ -77,6 +81,7 @@ module API # Ensure the namespace is right, otherwise we might load Grape::API::Helpers helpers ::SentryHelper helpers ::API::Helpers + helpers ::API::Helpers::CommonHelpers # Keep in alphabetical order mount ::API::AccessRequests diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 6d6ccefe877..f8f5548d23d 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -256,7 +256,11 @@ module API class IssueBasic < ProjectEntity expose :label_names, as: :labels expose :milestone, using: Entities::Milestone - expose :assignee, :author, using: Entities::UserBasic + expose :assignees, :author, using: Entities::UserBasic + + expose :assignee, using: ::API::Entities::UserBasic do |issue, options| + issue.assignees.first + end expose :user_notes_count expose :upvotes, :downvotes diff --git a/lib/api/helpers/common_helpers.rb b/lib/api/helpers/common_helpers.rb new file mode 100644 index 00000000000..6236fdd43ca --- /dev/null +++ b/lib/api/helpers/common_helpers.rb @@ -0,0 +1,13 @@ +module API + module Helpers + module CommonHelpers + def convert_parameters_from_legacy_format(params) + if params[:assignee_id].present? + params[:assignee_ids] = [params.delete(:assignee_id)] + end + + params + end + end + end +end diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 718f936a1fc..264df7271a3 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -1,48 +1,14 @@ module API module Helpers module InternalHelpers - # Project paths may be any of the following: - # * /repository/storage/path/namespace/project - # * /namespace/project - # * namespace/project - # - # In addition, they may have a '.git' extension and multiple namespaces - # - # Transform all these cases to 'namespace/project' - def clean_project_path(project_path, storages = Gitlab.config.repositories.storages.values) - project_path = project_path.sub(/\.git\z/, '') - - storages.each do |storage| - storage_path = File.expand_path(storage['path']) - - if project_path.start_with?(storage_path) - project_path = project_path.sub(storage_path, '') - break - end - end - - project_path.sub(/\A\//, '') - end - - def project_path - @project_path ||= clean_project_path(params[:project]) - end - def wiki? - @wiki ||= project_path.end_with?('.wiki') && - !Project.find_by_full_path(project_path) + set_project unless defined?(@wiki) + @wiki end def project - @project ||= begin - # Check for *.wiki repositories. - # Strip out the .wiki from the pathname before finding the - # project. This applies the correct project permissions to - # the wiki repository as well. - project_path.chomp!('.wiki') if wiki? - - Project.find_by_full_path(project_path) - end + set_project unless defined?(@project) + @project end def ssh_authentication_abilities @@ -66,6 +32,16 @@ module API ::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action]) end + + private + + def set_project + if params[:gl_repository] + @project, @wiki = Gitlab::GlRepository.parse(params[:gl_repository]) + else + @project, @wiki = Gitlab::RepoPath.parse(params[:project]) + end + end end end end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index ebed26dd178..2a11790b215 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -42,6 +42,10 @@ module API if access_status.status log_user_activity(actor) + # Project id to pass between components that don't share/don't have + # access to the same filesystem mounts + response[:gl_repository] = Gitlab::GlRepository.gl_repository(project, wiki?) + # Return the repository full path so that gitlab-shell has it when # handling ssh commands response[:repository_path] = @@ -134,11 +138,9 @@ module API return unless Gitlab::GitalyClient.enabled? - relative_path = Gitlab::RepoPath.strip_storage_path(params[:repo_path]) - project = Project.find_by_full_path(relative_path.sub(/\.(git|wiki)\z/, '')) - begin - Gitlab::GitalyClient::Notifications.new(project.repository).post_receive + repository = wiki? ? project.wiki.repository : project.repository + Gitlab::GitalyClient::Notifications.new(repository.raw_repository).post_receive rescue GRPC::Unavailable => e render_api_error!(e, 500) end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 522f0f3be92..78db960ae28 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -32,7 +32,8 @@ module API params :issue_params_ce do optional :description, type: String, desc: 'The description of an issue' - optional :assignee_id, type: Integer, desc: 'The ID of a user to assign issue' + optional :assignee_ids, type: Array[Integer], desc: 'The array of user IDs to assign issue' + optional :assignee_id, type: Integer, desc: '[Deprecated] The ID of a user to assign issue' optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign issue' optional :labels, type: String, desc: 'Comma-separated list of label names' optional :due_date, type: String, desc: 'Date string in the format YEAR-MONTH-DAY' @@ -135,6 +136,8 @@ module API issue_params = declared_params(include_missing: false) + issue_params = convert_parameters_from_legacy_format(issue_params) + issue = ::Issues::CreateService.new(user_project, current_user, issue_params.merge(request: request, api: true)).execute @@ -159,7 +162,7 @@ module API desc: 'Date time when the issue was updated. Available only for admins and project owners.' optional :state_event, type: String, values: %w[reopen close], desc: 'State of the issue' use :issue_params - at_least_one_of :title, :description, :assignee_id, :milestone_id, + at_least_one_of :title, :description, :assignee_ids, :assignee_id, :milestone_id, :labels, :created_at, :due_date, :confidential, :state_event end put ':id/issues/:issue_iid' do @@ -173,6 +176,8 @@ module API update_params = declared_params(include_missing: false).merge(request: request, api: true) + update_params = convert_parameters_from_legacy_format(update_params) + issue = ::Issues::UpdateService.new(user_project, current_user, update_params).execute(issue) diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb index 754c3d85a04..9117704aa46 100644 --- a/lib/api/pipelines.rb +++ b/lib/api/pipelines.rb @@ -14,13 +14,23 @@ module API end params do use :pagination - optional :scope, type: String, values: %w(running branches tags), - desc: 'Either running, branches, or tags' + optional :scope, type: String, values: %w[running pending finished branches tags], + desc: 'The scope of pipelines' + optional :status, type: String, values: HasStatus::AVAILABLE_STATUSES, + desc: 'The status of pipelines' + optional :ref, type: String, desc: 'The ref of pipelines' + optional :yaml_errors, type: Boolean, desc: 'Returns pipelines with invalid configurations' + optional :name, type: String, desc: 'The name of the user who triggered pipelines' + optional :username, type: String, desc: 'The username of the user who triggered pipelines' + optional :order_by, type: String, values: PipelinesFinder::ALLOWED_INDEXED_COLUMNS, default: 'id', + desc: 'Order pipelines' + optional :sort, type: String, values: %w[asc desc], default: 'desc', + desc: 'Sort pipelines' end get ':id/pipelines' do authorize! :read_pipeline, user_project - pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope]) + pipelines = PipelinesFinder.new(user_project, params).execute present paginate(pipelines), with: Entities::PipelineBasic end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index db4b31b55bc..9a6cb43abf7 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -26,6 +26,10 @@ module API params :optional_params do use :optional_params_ce end + + params :statistics_params do + optional :statistics, type: Boolean, default: false, desc: 'Include project statistics' + end end resource :projects do @@ -56,10 +60,6 @@ module API optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of' end - params :statistics_params do - optional :statistics, type: Boolean, default: false, desc: 'Include project statistics' - end - params :create_params do optional :namespace_id, type: Integer, desc: 'Namespace ID for the new project. Default to the user namespace.' optional :import_url, type: String, desc: 'URL from which the project is imported' @@ -85,6 +85,7 @@ module API end params do use :collection_params + use :statistics_params end get do entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails @@ -151,10 +152,13 @@ module API desc 'Get a single project' do success Entities::ProjectWithAccess end + params do + use :statistics_params + end get ":id" do entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails present user_project, with: entity, current_user: current_user, - user_can_admin_project: can?(current_user, :admin_project, user_project) + user_can_admin_project: can?(current_user, :admin_project, user_project), statistics: params[:statistics] end desc 'Get events for a single project' do @@ -381,7 +385,7 @@ module API requires :file, type: File, desc: 'The file to be uploaded' end post ":id/uploads" do - ::Projects::UploadService.new(user_project, params[:file]).execute + UploadService.new(user_project, params[:file]).execute end desc 'Get the users list of a project' do diff --git a/lib/api/settings.rb b/lib/api/settings.rb index d01c7f2703b..82f513c984e 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -58,6 +58,7 @@ module API :restricted_visibility_levels, :send_user_confirmation_email, :sentry_enabled, + :clientside_sentry_enabled, :session_expire_delay, :shared_runners_enabled, :sidekiq_throttling_enabled, @@ -138,6 +139,10 @@ module API given sentry_enabled: ->(val) { val } do requires :sentry_dsn, type: String, desc: 'Sentry Data Source Name' end + optional :clientside_sentry_enabled, type: Boolean, desc: 'Sentry can also be used for reporting and logging clientside exceptions. https://sentry.io/for/javascript/' + given clientside_sentry_enabled: ->(val) { val } do + requires :clientside_sentry_dsn, type: String, desc: 'Clientside Sentry Data Source Name' + end optional :repository_storage, type: String, desc: 'Storage paths for new projects' optional :repository_checks_enabled, type: Boolean, desc: "GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues." optional :koding_enabled, type: Boolean, desc: 'Enable Koding' diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 832b4bdeb4f..7c8be7e51db 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -248,6 +248,13 @@ module API expose :project_id, :issues_events, :merge_requests_events expose :note_events, :build_events, :pipeline_events, :wiki_page_events end + + class Issue < ::API::Entities::Issue + unexpose :assignees + expose :assignee do |issue, options| + ::API::Entities::UserBasic.represent(issue.assignees.first, options) + end + end end end end diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb index 715083fc4f8..cb371fdbab8 100644 --- a/lib/api/v3/issues.rb +++ b/lib/api/v3/issues.rb @@ -8,6 +8,7 @@ module API helpers do def find_issues(args = {}) args = params.merge(args) + args = convert_parameters_from_legacy_format(args) args.delete(:id) args[:milestone_title] = args.delete(:milestone) @@ -51,7 +52,7 @@ module API resource :issues do desc "Get currently authenticated user's issues" do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -61,7 +62,7 @@ module API get do issues = find_issues(scope: 'authored') - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user end end @@ -70,7 +71,7 @@ module API end resource :groups, requirements: { id: %r{[^/]+} } do desc 'Get a list of group issues' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -82,7 +83,7 @@ module API issues = find_issues(group_id: group.id, match_all_labels: true) - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user end end @@ -94,7 +95,7 @@ module API desc 'Get a list of project issues' do detail 'iid filter is deprecated have been removed on V4' - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -107,22 +108,22 @@ module API issues = find_issues(project_id: project.id) - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end desc 'Get a single project issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' end get ":id/issues/:issue_id" do issue = find_project_issue(params[:issue_id]) - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end desc 'Create a new project issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :title, type: String, desc: 'The title of an issue' @@ -140,6 +141,7 @@ module API issue_params = declared_params(include_missing: false) issue_params = issue_params.merge(merge_request_to_resolve_discussions_of: issue_params.delete(:merge_request_for_resolving_discussions)) + issue_params = convert_parameters_from_legacy_format(issue_params) issue = ::Issues::CreateService.new(user_project, current_user, @@ -147,14 +149,14 @@ module API render_spam_error! if issue.spam? if issue.valid? - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end end desc 'Update an existing issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' @@ -176,6 +178,7 @@ module API end update_params = declared_params(include_missing: false).merge(request: request, api: true) + update_params = convert_parameters_from_legacy_format(update_params) issue = ::Issues::UpdateService.new(user_project, current_user, @@ -184,14 +187,14 @@ module API render_spam_error! if issue.spam? if issue.valid? - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end end desc 'Move an existing issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' @@ -206,7 +209,7 @@ module API begin issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project) - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project rescue ::Issues::MoveService::MoveError => error render_api_error!(error.message, 400) end diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb index 1616142a619..b6b7254ae29 100644 --- a/lib/api/v3/merge_requests.rb +++ b/lib/api/v3/merge_requests.rb @@ -34,7 +34,7 @@ module API if project.has_external_issue_tracker? ::API::Entities::ExternalIssue else - ::API::Entities::Issue + ::API::V3::Entities::Issue end end diff --git a/lib/api/v3/milestones.rb b/lib/api/v3/milestones.rb index be90cec4afc..4c7061d4939 100644 --- a/lib/api/v3/milestones.rb +++ b/lib/api/v3/milestones.rb @@ -39,7 +39,7 @@ module API end desc 'Get all issues for a single project milestone' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :milestone_id, type: Integer, desc: 'The ID of a project milestone' @@ -56,7 +56,7 @@ module API } issues = IssuesFinder.new(current_user, finder_params).execute - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end end end diff --git a/lib/api/v3/pipelines.rb b/lib/api/v3/pipelines.rb index 82827249244..c48cbd2b765 100644 --- a/lib/api/v3/pipelines.rb +++ b/lib/api/v3/pipelines.rb @@ -21,7 +21,7 @@ module API get ':id/pipelines' do authorize! :read_pipeline, user_project - pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope]) + pipelines = PipelinesFinder.new(user_project, scope: params[:scope]).execute present paginate(pipelines), with: ::API::Entities::Pipeline end end diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb index ba9748ada59..06cc704afc6 100644 --- a/lib/api/v3/projects.rb +++ b/lib/api/v3/projects.rb @@ -452,7 +452,7 @@ module API requires :file, type: File, desc: 'The file to be uploaded' end post ":id/uploads" do - ::Projects::UploadService.new(user_project, params[:file]).execute + UploadService.new(user_project, params[:file]).execute end desc 'Get the users list of a project' do diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index e02b360924a..89ec715ddf6 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -28,7 +28,7 @@ module Banzai nodes, Issue.all.includes( :author, - :assignee, + :assignees, { # These associations are primarily used for checking permissions. # Eager loading these ensures we don't end up running dozens of diff --git a/lib/banzai/reference_parser/merge_request_parser.rb b/lib/banzai/reference_parser/merge_request_parser.rb index 84a28b33d7c..8b0662749fd 100644 --- a/lib/banzai/reference_parser/merge_request_parser.rb +++ b/lib/banzai/reference_parser/merge_request_parser.rb @@ -33,7 +33,8 @@ module Banzai { namespace: :owner }, { group: [:owners, :group_members] }, :invited_groups, - :project_members + :project_members, + :project_feature ] }), self.class.data_attribute diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb index bae4db1ca4d..5f379756c11 100644 --- a/lib/constraints/group_url_constrainer.rb +++ b/lib/constraints/group_url_constrainer.rb @@ -2,16 +2,8 @@ class GroupUrlConstrainer def matches?(request) id = request.params[:id] - return false unless valid?(id) + return false unless DynamicPathValidator.valid?(id) - Group.find_by_full_path(id).present? - end - - private - - def valid?(id) - id.split('/').all? do |namespace| - NamespaceValidator.valid?(namespace) - end + Group.find_by_full_path(id, follow_redirects: request.get?).present? end end diff --git a/lib/constraints/project_url_constrainer.rb b/lib/constraints/project_url_constrainer.rb index a10b4657d7d..6f542f63f98 100644 --- a/lib/constraints/project_url_constrainer.rb +++ b/lib/constraints/project_url_constrainer.rb @@ -4,10 +4,8 @@ class ProjectUrlConstrainer project_path = request.params[:project_id] || request.params[:id] full_path = namespace_path + '/' + project_path - unless ProjectPathValidator.valid?(project_path) - return false - end + return false unless DynamicPathValidator.valid?(full_path) - Project.find_by_full_path(full_path).present? + Project.find_by_full_path(full_path, follow_redirects: request.get?).present? end end diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb index 9ab5bcb12ff..28159dc0dec 100644 --- a/lib/constraints/user_url_constrainer.rb +++ b/lib/constraints/user_url_constrainer.rb @@ -1,5 +1,5 @@ class UserUrlConstrainer def matches?(request) - User.find_by_username(request.params[:username]).present? + User.find_by_full_path(request.params[:username], follow_redirects: request.get?).present? end end diff --git a/lib/github/import.rb b/lib/github/import.rb index d49761fd6c6..06beb607a3e 100644 --- a/lib/github/import.rb +++ b/lib/github/import.rb @@ -245,7 +245,7 @@ module Github issue.label_ids = label_ids(representation.labels) issue.milestone_id = milestone_id(representation.milestone) issue.author_id = author_id - issue.assignee_id = user_id(representation.assignee) + issue.assignee_ids = [user_id(representation.assignee)] issue.created_at = representation.created_at issue.updated_at = representation.updated_at issue.save!(validate: false) diff --git a/lib/gitlab/chat_commands/presenters/issue_base.rb b/lib/gitlab/chat_commands/presenters/issue_base.rb index 054f7f4be0c..25bc82994ba 100644 --- a/lib/gitlab/chat_commands/presenters/issue_base.rb +++ b/lib/gitlab/chat_commands/presenters/issue_base.rb @@ -22,7 +22,7 @@ module Gitlab [ { title: "Assignee", - value: @resource.assignee ? @resource.assignee.name : "_None_", + value: @resource.assignees.any? ? @resource.assignees.first.name : "_None_", short: true }, { diff --git a/lib/gitlab/ci/build/artifacts/metadata/entry.rb b/lib/gitlab/ci/build/artifacts/metadata/entry.rb index 6f799c2f031..2e073334abc 100644 --- a/lib/gitlab/ci/build/artifacts/metadata/entry.rb +++ b/lib/gitlab/ci/build/artifacts/metadata/entry.rb @@ -37,6 +37,12 @@ module Gitlab !directory? end + def blob + return unless file? + + @blob ||= Blob.decorate(::Ci::ArtifactBlob.new(self), nil) + end + def has_parent? nodes > 0 end diff --git a/lib/gitlab/ci/cron_parser.rb b/lib/gitlab/ci/cron_parser.rb index a3cc350ef22..dad8c3cdf5b 100644 --- a/lib/gitlab/ci/cron_parser.rb +++ b/lib/gitlab/ci/cron_parser.rb @@ -6,7 +6,7 @@ module Gitlab def initialize(cron, cron_timezone = 'UTC') @cron = cron - @cron_timezone = cron_timezone + @cron_timezone = ActiveSupport::TimeZone.find_tzinfo(cron_timezone).name end def next_time_from(time) @@ -24,8 +24,23 @@ module Gitlab private + # NOTE: + # cron_timezone can only accept timezones listed in TZInfo::Timezone. + # Aliases of Timezones from ActiveSupport::TimeZone are NOT accepted, + # because Rufus::Scheduler only supports TZInfo::Timezone. + # + # For example, those codes have the same effect. + # Time.zone = 'Pacific Time (US & Canada)' (ActiveSupport::TimeZone) + # Time.zone = 'America/Los_Angeles' (TZInfo::Timezone) + # + # However, try_parse_cron only accepts the latter format. + # try_parse_cron('* * * * *', 'Pacific Time (US & Canada)') -> Doesn't work + # try_parse_cron('* * * * *', 'America/Los_Angeles') -> Works + # If you want to know more, please take a look + # https://github.com/rails/rails/blob/master/activesupport/lib/active_support/values/time_zone.rb def try_parse_cron(cron, cron_timezone) - Rufus::Scheduler.parse("#{cron} #{cron_timezone}") + cron_line = Rufus::Scheduler.parse("#{cron} #{cron_timezone}") + cron_line if cron_line.is_a?(Rufus::Scheduler::CronLine) rescue # noop end diff --git a/lib/gitlab/ci/status/group/common.rb b/lib/gitlab/ci/status/group/common.rb new file mode 100644 index 00000000000..cfd4329a923 --- /dev/null +++ b/lib/gitlab/ci/status/group/common.rb @@ -0,0 +1,21 @@ +module Gitlab + module Ci + module Status + module Group + module Common + def has_details? + false + end + + def details_path + nil + end + + def has_action? + false + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/group/factory.rb b/lib/gitlab/ci/status/group/factory.rb new file mode 100644 index 00000000000..d118116cfc3 --- /dev/null +++ b/lib/gitlab/ci/status/group/factory.rb @@ -0,0 +1,13 @@ +module Gitlab + module Ci + module Status + module Group + class Factory < Status::Factory + def self.common_helpers + Status::Group::Common + end + end + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/base_stage.rb b/lib/gitlab/cycle_analytics/base_stage.rb index 559e3939da6..cac31ea8cff 100644 --- a/lib/gitlab/cycle_analytics/base_stage.rb +++ b/lib/gitlab/cycle_analytics/base_stage.rb @@ -17,7 +17,7 @@ module Gitlab end def title - name.to_s.capitalize + raise NotImplementedError.new("Expected #{self.name} to implement title") end def median diff --git a/lib/gitlab/cycle_analytics/code_stage.rb b/lib/gitlab/cycle_analytics/code_stage.rb index 1e52b6614a1..5f9dc9a4303 100644 --- a/lib/gitlab/cycle_analytics/code_stage.rb +++ b/lib/gitlab/cycle_analytics/code_stage.rb @@ -13,12 +13,16 @@ module Gitlab :code end + def title + s_('CycleAnalyticsStage|Code') + end + def legend - "Related Merge Requests" + _("Related Merge Requests") end def description - "Time until first merge request" + _("Time until first merge request") end end end diff --git a/lib/gitlab/cycle_analytics/issue_stage.rb b/lib/gitlab/cycle_analytics/issue_stage.rb index 213994988a5..7b03811efb2 100644 --- a/lib/gitlab/cycle_analytics/issue_stage.rb +++ b/lib/gitlab/cycle_analytics/issue_stage.rb @@ -14,12 +14,16 @@ module Gitlab :issue end + def title + s_('CycleAnalyticsStage|Issue') + end + def legend - "Related Issues" + _("Related Issues") end def description - "Time before an issue gets scheduled" + _("Time before an issue gets scheduled") end end end diff --git a/lib/gitlab/cycle_analytics/plan_stage.rb b/lib/gitlab/cycle_analytics/plan_stage.rb index 45d51d30ccc..1a0afb56b4f 100644 --- a/lib/gitlab/cycle_analytics/plan_stage.rb +++ b/lib/gitlab/cycle_analytics/plan_stage.rb @@ -14,12 +14,16 @@ module Gitlab :plan end + def title + s_('CycleAnalyticsStage|Plan') + end + def legend - "Related Commits" + _("Related Commits") end def description - "Time before an issue starts implementation" + _("Time before an issue starts implementation") end end end diff --git a/lib/gitlab/cycle_analytics/production_stage.rb b/lib/gitlab/cycle_analytics/production_stage.rb index 9f387a02945..0fa8a65cb99 100644 --- a/lib/gitlab/cycle_analytics/production_stage.rb +++ b/lib/gitlab/cycle_analytics/production_stage.rb @@ -15,12 +15,16 @@ module Gitlab :production end + def title + s_('CycleAnalyticsStage|Production') + end + def legend - "Related Issues" + _("Related Issues") end def description - "From issue creation until deploy to production" + _("From issue creation until deploy to production") end def query diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb index 4744be834de..cfbbdc43fd9 100644 --- a/lib/gitlab/cycle_analytics/review_stage.rb +++ b/lib/gitlab/cycle_analytics/review_stage.rb @@ -13,12 +13,16 @@ module Gitlab :review end + def title + s_('CycleAnalyticsStage|Review') + end + def legend - "Relative Merged Requests" + _("Related Merged Requests") end def description - "Time between merge request creation and merge/close" + _("Time between merge request creation and merge/close") end end end diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb index 3cdbe04fbaf..d5684bb9201 100644 --- a/lib/gitlab/cycle_analytics/staging_stage.rb +++ b/lib/gitlab/cycle_analytics/staging_stage.rb @@ -14,12 +14,16 @@ module Gitlab :staging end + def title + s_('CycleAnalyticsStage|Staging') + end + def legend - "Relative Deployed Builds" + _("Related Deployed Jobs") end def description - "From merge request merge until deploy to production" + _("From merge request merge until deploy to production") end end end diff --git a/lib/gitlab/cycle_analytics/summary/base.rb b/lib/gitlab/cycle_analytics/summary/base.rb index 43fa3795e5c..a917ddccac7 100644 --- a/lib/gitlab/cycle_analytics/summary/base.rb +++ b/lib/gitlab/cycle_analytics/summary/base.rb @@ -8,7 +8,7 @@ module Gitlab end def title - self.class.name.demodulize + raise NotImplementedError.new("Expected #{self.name} to implement title") end def value diff --git a/lib/gitlab/cycle_analytics/summary/commit.rb b/lib/gitlab/cycle_analytics/summary/commit.rb index 7b8faa4d854..bea78862757 100644 --- a/lib/gitlab/cycle_analytics/summary/commit.rb +++ b/lib/gitlab/cycle_analytics/summary/commit.rb @@ -2,6 +2,10 @@ module Gitlab module CycleAnalytics module Summary class Commit < Base + def title + n_('Commit', 'Commits', value) + end + def value @value ||= count_commits end diff --git a/lib/gitlab/cycle_analytics/summary/deploy.rb b/lib/gitlab/cycle_analytics/summary/deploy.rb index 06032e9200e..099d798aac6 100644 --- a/lib/gitlab/cycle_analytics/summary/deploy.rb +++ b/lib/gitlab/cycle_analytics/summary/deploy.rb @@ -2,6 +2,10 @@ module Gitlab module CycleAnalytics module Summary class Deploy < Base + def title + n_('Deploy', 'Deploys', value) + end + def value @value ||= @project.deployments.where("created_at > ?", @from).count end diff --git a/lib/gitlab/cycle_analytics/summary/issue.rb b/lib/gitlab/cycle_analytics/summary/issue.rb index 008468f24b9..9bbf7a2685f 100644 --- a/lib/gitlab/cycle_analytics/summary/issue.rb +++ b/lib/gitlab/cycle_analytics/summary/issue.rb @@ -9,7 +9,7 @@ module Gitlab end def title - 'New Issue' + n_('New Issue', 'New Issues', value) end def value diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb index e96943833bc..2b5f72bef89 100644 --- a/lib/gitlab/cycle_analytics/test_stage.rb +++ b/lib/gitlab/cycle_analytics/test_stage.rb @@ -13,12 +13,16 @@ module Gitlab :test end + def title + s_('CycleAnalyticsStage|Test') + end + def legend - "Relative Builds Trigger by Commits" + _("Related Jobs") end def description - "Total test time for all commits/merges" + _("Total test time for all commits/merges") end def stage_query diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 6dabbe0264c..298b1a1f4e6 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -498,6 +498,29 @@ module Gitlab columns(table).find { |column| column.name == name } end + + # This will replace the first occurance of a string in a column with + # the replacement + # On postgresql we can use `regexp_replace` for that. + # On mysql we find the location of the pattern, and overwrite it + # with the replacement + def replace_sql(column, pattern, replacement) + quoted_pattern = Arel::Nodes::Quoted.new(pattern.to_s) + quoted_replacement = Arel::Nodes::Quoted.new(replacement.to_s) + + if Database.mysql? + locate = Arel::Nodes::NamedFunction. + new('locate', [quoted_pattern, column]) + insert_in_place = Arel::Nodes::NamedFunction. + new('insert', [column, locate, pattern.size, quoted_replacement]) + + Arel::Nodes::SqlLiteral.new(insert_in_place.to_sql) + else + replace = Arel::Nodes::NamedFunction. + new("regexp_replace", [column, quoted_pattern, quoted_replacement]) + Arel::Nodes::SqlLiteral.new(replace.to_sql) + end + end end end end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1.rb new file mode 100644 index 00000000000..89530082cd2 --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1.rb @@ -0,0 +1,35 @@ +# This module can be included in migrations to make it easier to rename paths +# of `Namespace` & `Project` models certain paths would become `reserved`. +# +# If the way things are stored on the filesystem related to namespaces and +# projects ever changes. Don't update this module, or anything nested in `V1`, +# since it needs to keep functioning for all migrations using it using the state +# that the data is in at the time. Instead, create a `V2` module that implements +# the new way of reserving paths. +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + def self.included(kls) + kls.include(MigrationHelpers) + end + + def rename_wildcard_paths(one_or_more_paths) + rename_child_paths(one_or_more_paths) + paths = Array(one_or_more_paths) + RenameProjects.new(paths, self).rename_projects + end + + def rename_child_paths(one_or_more_paths) + paths = Array(one_or_more_paths) + RenameNamespaces.new(paths, self).rename_namespaces(type: :child) + end + + def rename_root_paths(paths) + paths = Array(paths) + RenameNamespaces.new(paths, self).rename_namespaces(type: :top_level) + end + end + end + end +end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb new file mode 100644 index 00000000000..4fdcb682c2f --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb @@ -0,0 +1,76 @@ +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + module MigrationClasses + module Routable + def full_path + if route && route.path.present? + @full_path ||= route.path + else + update_route if persisted? + + build_full_path + end + end + + def build_full_path + if parent && path + parent.full_path + '/' + path + else + path + end + end + + def update_route + prepare_route + route.save + end + + def prepare_route + route || build_route(source: self) + route.path = build_full_path + @full_path = nil + end + end + + class Namespace < ActiveRecord::Base + include MigrationClasses::Routable + self.table_name = 'namespaces' + belongs_to :parent, + class_name: "#{MigrationClasses.name}::Namespace" + has_one :route, as: :source + has_many :children, + class_name: "#{MigrationClasses.name}::Namespace", + foreign_key: :parent_id + + # Overridden to have the correct `source_type` for the `route` relation + def self.name + 'Namespace' + end + end + + class Route < ActiveRecord::Base + self.table_name = 'routes' + belongs_to :source, polymorphic: true + end + + class Project < ActiveRecord::Base + include MigrationClasses::Routable + has_one :route, as: :source + self.table_name = 'projects' + + def repository_storage_path + Gitlab.config.repositories.storages[repository_storage]['path'] + end + + # Overridden to have the correct `source_type` for the `route` relation + def self.name + 'Project' + end + end + end + end + end + end +end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb new file mode 100644 index 00000000000..de4e6e7c404 --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb @@ -0,0 +1,131 @@ +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + class RenameBase + attr_reader :paths, :migration + + delegate :update_column_in_batches, + :replace_sql, + to: :migration + + def initialize(paths, migration) + @paths = paths + @migration = migration + end + + def path_patterns + @path_patterns ||= paths.map { |path| "%#{path}" } + end + + def rename_path_for_routable(routable) + old_path = routable.path + old_full_path = routable.full_path + # Only remove the last occurrence of the path name to get the parent namespace path + namespace_path = remove_last_occurrence(old_full_path, old_path) + new_path = rename_path(namespace_path, old_path) + new_full_path = join_routable_path(namespace_path, new_path) + + # skips callbacks & validations + routable.class.where(id: routable). + update_all(path: new_path) + + rename_routes(old_full_path, new_full_path) + + [old_full_path, new_full_path] + end + + def rename_routes(old_full_path, new_full_path) + replace_statement = replace_sql(Route.arel_table[:path], + old_full_path, + new_full_path) + + update_column_in_batches(:routes, :path, replace_statement) do |table, query| + query.where(MigrationClasses::Route.arel_table[:path].matches("#{old_full_path}%")) + end + end + + def rename_path(namespace_path, path_was) + counter = 0 + path = "#{path_was}#{counter}" + + while route_exists?(join_routable_path(namespace_path, path)) + counter += 1 + path = "#{path_was}#{counter}" + end + + path + end + + def remove_last_occurrence(string, pattern) + string.reverse.sub(pattern.reverse, "").reverse + end + + def join_routable_path(namespace_path, top_level) + if namespace_path.present? + File.join(namespace_path, top_level) + else + top_level + end + end + + def route_exists?(full_path) + MigrationClasses::Route.where(Route.arel_table[:path].matches(full_path)).any? + end + + def move_pages(old_path, new_path) + move_folders(pages_dir, old_path, new_path) + end + + def move_uploads(old_path, new_path) + return unless file_storage? + + move_folders(uploads_dir, old_path, new_path) + end + + def move_folders(directory, old_relative_path, new_relative_path) + old_path = File.join(directory, old_relative_path) + return unless File.directory?(old_path) + + new_path = File.join(directory, new_relative_path) + FileUtils.mv(old_path, new_path) + end + + def remove_cached_html_for_projects(project_ids) + update_column_in_batches(:projects, :description_html, nil) do |table, query| + query.where(table[:id].in(project_ids)) + end + + update_column_in_batches(:issues, :description_html, nil) do |table, query| + query.where(table[:project_id].in(project_ids)) + end + + update_column_in_batches(:merge_requests, :description_html, nil) do |table, query| + query.where(table[:target_project_id].in(project_ids)) + end + + update_column_in_batches(:notes, :note_html, nil) do |table, query| + query.where(table[:project_id].in(project_ids)) + end + + update_column_in_batches(:milestones, :description_html, nil) do |table, query| + query.where(table[:project_id].in(project_ids)) + end + end + + def file_storage? + CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File + end + + def uploads_dir + File.join(CarrierWave.root, "uploads") + end + + def pages_dir + Settings.pages.path + end + end + end + end + end +end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb new file mode 100644 index 00000000000..b9f4f3cff3c --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb @@ -0,0 +1,72 @@ +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + class RenameNamespaces < RenameBase + include Gitlab::ShellAdapter + + def rename_namespaces(type:) + namespaces_for_paths(type: type).each do |namespace| + rename_namespace(namespace) + end + end + + def namespaces_for_paths(type:) + namespaces = case type + when :child + MigrationClasses::Namespace.where.not(parent_id: nil) + when :top_level + MigrationClasses::Namespace.where(parent_id: nil) + end + with_paths = MigrationClasses::Route.arel_table[:path]. + matches_any(path_patterns) + namespaces.joins(:route).where(with_paths) + end + + def rename_namespace(namespace) + old_full_path, new_full_path = rename_path_for_routable(namespace) + + move_repositories(namespace, old_full_path, new_full_path) + move_uploads(old_full_path, new_full_path) + move_pages(old_full_path, new_full_path) + remove_cached_html_for_projects(projects_for_namespace(namespace).map(&:id)) + end + + def move_repositories(namespace, old_full_path, new_full_path) + repo_paths_for_namespace(namespace).each do |repository_storage_path| + # Ensure old directory exists before moving it + gitlab_shell.add_namespace(repository_storage_path, old_full_path) + + unless gitlab_shell.mv_namespace(repository_storage_path, old_full_path, new_full_path) + message = "Exception moving path #{repository_storage_path} \ + from #{old_full_path} to #{new_full_path}" + Rails.logger.error message + end + end + end + + def repo_paths_for_namespace(namespace) + projects_for_namespace(namespace).distinct.select(:repository_storage). + map(&:repository_storage_path) + end + + def projects_for_namespace(namespace) + namespace_ids = child_ids_for_parent(namespace, ids: [namespace.id]) + namespace_or_children = MigrationClasses::Project. + arel_table[:namespace_id]. + in(namespace_ids) + MigrationClasses::Project.where(namespace_or_children) + end + + def child_ids_for_parent(namespace, ids: []) + namespace.children.each do |child| + ids << child.id + child_ids_for_parent(child, ids: ids) if child.children.any? + end + ids + end + end + end + end + end +end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb new file mode 100644 index 00000000000..448717eb744 --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb @@ -0,0 +1,45 @@ +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + class RenameProjects < RenameBase + include Gitlab::ShellAdapter + + def rename_projects + projects_for_paths.each do |project| + rename_project(project) + end + + remove_cached_html_for_projects(projects_for_paths.map(&:id)) + end + + def rename_project(project) + old_full_path, new_full_path = rename_path_for_routable(project) + + move_repository(project, old_full_path, new_full_path) + move_repository(project, "#{old_full_path}.wiki", "#{new_full_path}.wiki") + move_uploads(old_full_path, new_full_path) + move_pages(old_full_path, new_full_path) + end + + def move_repository(project, old_path, new_path) + unless gitlab_shell.mv_repository(project.repository_storage_path, + old_path, + new_path) + Rails.logger.error "Error moving #{old_path} to #{new_path}" + end + end + + def projects_for_paths + return @projects_for_paths if @projects_for_paths + + with_paths = MigrationClasses::Route.arel_table[:path] + .matches_any(path_patterns) + + @projects_for_paths = MigrationClasses::Project.joins(:route).where(with_paths) + end + end + end + end + end +end diff --git a/lib/gitlab/email/attachment_uploader.rb b/lib/gitlab/email/attachment_uploader.rb index 32cece8316b..83440ae227d 100644 --- a/lib/gitlab/email/attachment_uploader.rb +++ b/lib/gitlab/email/attachment_uploader.rb @@ -21,7 +21,7 @@ module Gitlab content_type: attachment.content_type } - link = ::Projects::UploadService.new(project, file).execute + link = UploadService.new(project, file).execute attachments << link if link ensure tmp.close! diff --git a/lib/gitlab/email/handler/create_issue_handler.rb b/lib/gitlab/email/handler/create_issue_handler.rb index e7f91607e7e..a616a80e8f5 100644 --- a/lib/gitlab/email/handler/create_issue_handler.rb +++ b/lib/gitlab/email/handler/create_issue_handler.rb @@ -37,7 +37,7 @@ module Gitlab end def metrics_params - super.merge(project: project) + super.merge(project: project&.full_path) end private diff --git a/lib/gitlab/email/handler/create_note_handler.rb b/lib/gitlab/email/handler/create_note_handler.rb index 31bb775c357..31579e94a87 100644 --- a/lib/gitlab/email/handler/create_note_handler.rb +++ b/lib/gitlab/email/handler/create_note_handler.rb @@ -29,7 +29,7 @@ module Gitlab end def metrics_params - super.merge(project: project) + super.merge(project: project&.full_path) end private diff --git a/lib/gitlab/email/handler/unsubscribe_handler.rb b/lib/gitlab/email/handler/unsubscribe_handler.rb index df70a063330..5894384da5d 100644 --- a/lib/gitlab/email/handler/unsubscribe_handler.rb +++ b/lib/gitlab/email/handler/unsubscribe_handler.rb @@ -20,7 +20,7 @@ module Gitlab end def metrics_params - super.merge(project: project) + super.merge(project: project&.full_path) end private diff --git a/lib/gitlab/email/receiver.rb b/lib/gitlab/email/receiver.rb index c270c0ea9ff..0d6b08b5d29 100644 --- a/lib/gitlab/email/receiver.rb +++ b/lib/gitlab/email/receiver.rb @@ -57,9 +57,8 @@ module Gitlab end def key_from_additional_headers(mail) - references = ensure_references_array(mail.references) - - find_key_from_references(references) + find_key_from_references(mail) || + find_key_from_delivered_to_header(mail) end def ensure_references_array(references) @@ -75,12 +74,19 @@ module Gitlab end end - def find_key_from_references(references) - references.find do |mail_id| + def find_key_from_references(mail) + ensure_references_array(mail.references).find do |mail_id| key = Gitlab::IncomingEmail.key_from_fallback_message_id(mail_id) break key if key end end + + def find_key_from_delivered_to_header(mail) + Array(mail[:delivered_to]).find do |header| + key = Gitlab::IncomingEmail.key_from_address(header.value) + break key if key + end + end end end end diff --git a/lib/gitlab/emoji.rb b/lib/gitlab/emoji.rb index a16d9fc2265..e3e36b35ce9 100644 --- a/lib/gitlab/emoji.rb +++ b/lib/gitlab/emoji.rb @@ -54,7 +54,7 @@ module Gitlab unicode_version: emoji_unicode_version(emoji_name) } - ActionController::Base.helpers.content_tag('gl-emoji', emoji_info['moji'], data: data) + ActionController::Base.helpers.content_tag('gl-emoji', emoji_info['moji'], title: emoji_info['description'], data: data) end end end diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index f6e4f279c06..692c909d838 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -2,33 +2,45 @@ module Gitlab module EtagCaching class Router Route = Struct.new(:regexp, :name) - - RESERVED_WORDS = NamespaceValidator::WILDCARD_ROUTES.map { |word| "/#{word}/" }.join('|') + # We enable an ETag for every request matching the regex. + # To match a regex the path needs to match the following: + # - Don't contain a reserved word (expect for the words used in the + # regex itself) + # - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route + # - Ending in `issues/id`/rendered_title` for the `issue_title` route + USED_IN_ROUTES = %w[noteable issue notes issues rendered_title + commit pipelines merge_requests new].freeze + RESERVED_WORDS = DynamicPathValidator::WILDCARD_ROUTES - USED_IN_ROUTES + RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS) ROUTES = [ Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/noteable/issue/\d+/notes\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z), 'issue_notes' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/issues/\d+/rendered_title\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/issues/\d+/rendered_title\z), 'issue_title' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/commit/\S+/pipelines\.json\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/commit/\S+/pipelines\.json\z), 'commit_pipelines' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/merge_requests/new\.json\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/merge_requests/new\.json\z), 'new_merge_request_pipelines' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/merge_requests/\d+/pipelines\.json\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/merge_requests/\d+/pipelines\.json\z), 'merge_request_pipelines' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/pipelines\.json\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines\.json\z), 'project_pipelines' - ) + ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS})).*/pipelines/\d+\.json\z), + 'project_pipeline' + ), ].freeze def self.match(env) diff --git a/lib/gitlab/fogbugz_import/importer.rb b/lib/gitlab/fogbugz_import/importer.rb index 222bcdcbf9c..3dcee681c72 100644 --- a/lib/gitlab/fogbugz_import/importer.rb +++ b/lib/gitlab/fogbugz_import/importer.rb @@ -122,15 +122,15 @@ module Gitlab author_id = user_info(bug['ixPersonOpenedBy'])[:gitlab_id] || project.creator_id issue = Issue.create!( - iid: bug['ixBug'], - project_id: project.id, - title: bug['sTitle'], - description: body, - author_id: author_id, - assignee_id: assignee_id, - state: bug['fOpen'] == 'true' ? 'opened' : 'closed', - created_at: date, - updated_at: DateTime.parse(bug['dtLastUpdated']) + iid: bug['ixBug'], + project_id: project.id, + title: bug['sTitle'], + description: body, + author_id: author_id, + assignee_ids: [assignee_id], + state: bug['fOpen'] == 'true' ? 'opened' : 'closed', + created_at: date, + updated_at: DateTime.parse(bug['dtLastUpdated']) ) issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true) diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index e8bb9e1f805..12458f9f410 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -128,6 +128,10 @@ module Gitlab encode! @name end + def truncated? + size && (size > loaded_size) + end + # Valid LFS object pointer is a text file consisting of # version # oid @@ -155,10 +159,14 @@ module Gitlab nil end - def truncated? - size && (size > loaded_size) + def external_storage + return unless lfs_pointer? + + :lfs end + alias_method :external_size, :lfs_size + private def has_lfs_version_key? diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index c3f0de76d01..6a0f12b7e50 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -45,17 +45,13 @@ module Gitlab # Default branch in the repository def root_ref - # NOTE: This feature is intentionally disabled until - # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved - # @root_ref ||= Gitlab::GitalyClient.migrate(:root_ref) do |is_enabled| - # if is_enabled - # gitaly_ref_client.default_branch_name - # else - @root_ref ||= discover_default_branch - # end - # end - rescue GRPC::BadStatus => e - raise CommandError.new(e) + @root_ref ||= gitaly_migrate(:root_ref) do |is_enabled| + if is_enabled + gitaly_ref_client.default_branch_name + else + discover_default_branch + end + end end # Alias to old method for compatibility @@ -72,17 +68,13 @@ module Gitlab # Returns an Array of branch names # sorted by name ASC def branch_names - # Gitlab::GitalyClient.migrate(:branch_names) do |is_enabled| - # NOTE: This feature is intentionally disabled until - # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved - # if is_enabled - # gitaly_ref_client.branch_names - # else - branches.map(&:name) - # end - # end - rescue GRPC::BadStatus => e - raise CommandError.new(e) + gitaly_migrate(:branch_names) do |is_enabled| + if is_enabled + gitaly_ref_client.branch_names + else + branches.map(&:name) + end + end end # Returns an Array of Branches @@ -152,17 +144,13 @@ module Gitlab # Returns an Array of tag names def tag_names - # Gitlab::GitalyClient.migrate(:tag_names) do |is_enabled| - # NOTE: This feature is intentionally disabled until - # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved - # if is_enabled - # gitaly_ref_client.tag_names - # else - rugged.tags.map { |t| t.name } - # end - # end - rescue GRPC::BadStatus => e - raise CommandError.new(e) + gitaly_migrate(:tag_names) do |is_enabled| + if is_enabled + gitaly_ref_client.tag_names + else + rugged.tags.map { |t| t.name } + end + end end # Returns an Array of Tags @@ -511,8 +499,9 @@ module Gitlab # :contains is the commit contained by the refs from which to begin (SHA1 or name) # :max_count is the maximum number of commits to fetch # :skip is the number of commits to skip - # :order is the commits order and allowed value is :none (default), :date, or :topo - # commit ordering types are documented here: + # :order is the commits order and allowed value is :none (default), :date, + # :topo, or any combination of them (in an array). Commit ordering types + # are documented here: # http://www.rubydoc.info/github/libgit2/rugged/Rugged#SORT_NONE-constant) # def find_commits(options = {}) @@ -888,27 +877,6 @@ module Gitlab rugged.remotes[remote_name].push(refspecs) end - # Merge the +source_name+ branch into the +target_name+ branch. This is - # equivalent to `git merge --no_ff +source_name+`, since a merge commit - # is always created. - def merge(source_name, target_name, options = {}) - our_commit = rugged.branches[target_name].target - their_commit = rugged.branches[source_name].target - - raise "Invalid merge target" if our_commit.nil? - raise "Invalid merge source" if their_commit.nil? - - merge_index = rugged.merge_commits(our_commit, their_commit) - return false if merge_index.conflicts? - - actual_options = options.merge( - parents: [our_commit, their_commit], - tree: merge_index.write_tree(rugged), - update_ref: "refs/heads/#{target_name}" - ) - Rugged::Commit.create(rugged, actual_options) - end - AUTOCRLF_VALUES = { "true" => true, "false" => false, @@ -1294,16 +1262,26 @@ module Gitlab @gitaly_commit_client ||= Gitlab::GitalyClient::Commit.new(self) end - # Returns the `Rugged` sorting type constant for a given - # sort type key. Valid keys are `:none`, `:topo`, and `:date` - def rugged_sort_type(key) + def gitaly_migrate(method, &block) + Gitlab::GitalyClient.migrate(method, &block) + rescue GRPC::NotFound => e + raise NoRepository.new(e) + rescue GRPC::BadStatus => e + raise CommandError.new(e) + end + + # Returns the `Rugged` sorting type constant for one or more given + # sort types. Valid keys are `:none`, `:topo`, and `:date`, or an array + # containing more than one of them. `:date` uses a combination of date and + # topological sorting to closer mimic git's native ordering. + def rugged_sort_type(sort_type) @rugged_sort_types ||= { none: Rugged::SORT_NONE, topo: Rugged::SORT_TOPO, - date: Rugged::SORT_DATE + date: Rugged::SORT_DATE | Rugged::SORT_TOPO } - @rugged_sort_types.fetch(key, Rugged::SORT_NONE) + @rugged_sort_types.fetch(sort_type, Rugged::SORT_NONE) end end end diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb index 6babea144c7..0e14253ab4e 100644 --- a/lib/gitlab/git_post_receive.rb +++ b/lib/gitlab/git_post_receive.rb @@ -1,25 +1,12 @@ module Gitlab class GitPostReceive include Gitlab::Identifier - attr_reader :repo_path, :identifier, :changes, :project + attr_reader :project, :identifier, :changes - def initialize(repo_path, identifier, changes) - repo_path.gsub!(/\.git\z/, '') - repo_path.gsub!(/\A\//, '') - - @repo_path = repo_path + def initialize(project, identifier, changes) + @project = project @identifier = identifier @changes = deserialize_changes(changes) - - retrieve_project_and_type - end - - def wiki? - @type == :wiki - end - - def regular_project? - @type == :project end def identify(revision) @@ -28,16 +15,6 @@ module Gitlab private - def retrieve_project_and_type - @type = :project - @project = Project.find_by_full_path(@repo_path) - - if @repo_path.end_with?('.wiki') && !@project - @type = :wiki - @project = Project.find_by_full_path(@repo_path.gsub(/\.wiki\z/, '')) - end - end - def deserialize_changes(changes) changes = utf8_encode_changes(changes) changes.lines diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb index 2a5e8f73e55..f6c77ef1a3e 100644 --- a/lib/gitlab/gitaly_client/ref.rb +++ b/lib/gitlab/gitaly_client/ref.rb @@ -11,7 +11,9 @@ module Gitlab def default_branch_name request = Gitaly::FindDefaultBranchNameRequest.new(repository: @gitaly_repo) - stub.find_default_branch_name(request).name.gsub(/^refs\/heads\//, '') + branch_name = stub.find_default_branch_name(request).name + + Gitlab::Git.branch_name(branch_name) end def branch_names diff --git a/lib/gitlab/github_import/issue_formatter.rb b/lib/gitlab/github_import/issue_formatter.rb index 6f5ac4dac0d..977cd0423ba 100644 --- a/lib/gitlab/github_import/issue_formatter.rb +++ b/lib/gitlab/github_import/issue_formatter.rb @@ -10,7 +10,7 @@ module Gitlab description: description, state: state, author_id: author_id, - assignee_id: assignee_id, + assignee_ids: Array(assignee_id), created_at: raw_data.created_at, updated_at: raw_data.updated_at } diff --git a/lib/gitlab/gl_repository.rb b/lib/gitlab/gl_repository.rb new file mode 100644 index 00000000000..07c0abcce23 --- /dev/null +++ b/lib/gitlab/gl_repository.rb @@ -0,0 +1,20 @@ +module Gitlab + module GlRepository + def self.gl_repository(project, is_wiki) + "#{is_wiki ? 'wiki' : 'project'}-#{project.id}" + end + + def self.parse(gl_repository) + match_data = /\A(project|wiki)-([1-9][0-9]*)\z/.match(gl_repository) + unless match_data + raise ArgumentError, "Invalid GL Repository \"#{gl_repository}\"" + end + + type, id = match_data.captures + project = Project.find_by(id: id) + wiki = type == 'wiki' + + [project, wiki] + end + end +end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 5ab84266b7d..26473f99bc3 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -10,6 +10,8 @@ module Gitlab gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css') gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') + gon.sentry_dsn = current_application_settings.clientside_sentry_dsn if current_application_settings.clientside_sentry_enabled + gon.gitlab_url = Gitlab.config.gitlab.url if current_user gon.current_user_id = current_user.id diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 5ca3e6a95ca..1b43440673c 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -108,13 +108,13 @@ module Gitlab end issue = Issue.create!( - iid: raw_issue['id'], - project_id: project.id, - title: raw_issue['title'], - description: body, - author_id: project.creator_id, - assignee_id: assignee_id, - state: raw_issue['state'] == 'closed' ? 'closed' : 'opened' + iid: raw_issue['id'], + project_id: project.id, + title: raw_issue['title'], + description: body, + author_id: project.creator_id, + assignee_ids: [assignee_id], + state: raw_issue['state'] == 'closed' ? 'closed' : 'opened' ) issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true) diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb new file mode 100644 index 00000000000..3411516319f --- /dev/null +++ b/lib/gitlab/i18n.rb @@ -0,0 +1,26 @@ +module Gitlab + module I18n + extend self + + AVAILABLE_LANGUAGES = { + 'en' => 'English', + 'es' => 'Español', + 'de' => 'Deutsch' + }.freeze + + def available_locales + AVAILABLE_LANGUAGES.keys + end + + def set_locale(current_user) + requested_locale = current_user&.preferred_language || ::I18n.default_locale + locale = FastGettext.set_locale(requested_locale) + ::I18n.locale = locale + end + + def reset_locale + FastGettext.set_locale(::I18n.default_locale) + ::I18n.locale = ::I18n.default_locale + end + end +end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 899a6567768..5f757f99fb3 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -41,7 +41,6 @@ project_tree: - :statuses - triggers: - :trigger_schedule - - :deploy_keys - :services - :hooks - protected_branches: @@ -53,10 +52,6 @@ project_tree: # Only include the following attributes for the models specified. included_attributes: - project: - - :description - - :visibility_level - - :archived user: - :id - :email @@ -66,6 +61,32 @@ included_attributes: # Do not include the following attributes for the models specified. excluded_attributes: + project: + - :name + - :path + - :namespace_id + - :creator_id + - :import_url + - :import_status + - :avatar + - :import_type + - :import_source + - :import_error + - :mirror + - :runners_token + - :repository_storage + - :repository_read_only + - :lfs_enabled + - :import_jid + - :created_at + - :updated_at + - :import_jid + - :import_jid + - :id + - :star_count + - :last_activity_at + - :last_repository_updated_at + - :last_repository_check_at snippets: - :expired_at merge_request_diff: @@ -94,3 +115,5 @@ methods: - :utf8_st_diffs merge_requests: - :diff_head_sha + project: + - :description_html
\ No newline at end of file diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index 2e349b5f9a9..84ab1977dfa 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -71,14 +71,14 @@ module Gitlab def restore_project return @project unless @tree_hash - @project.update(project_params) + @project.update_columns(project_params) @project end def project_params @tree_hash.reject do |key, value| # return params that are not 1 to many or 1 to 1 relations - value.is_a?(Array) || key == key.singularize + value.respond_to?(:each) && !Project.column_names.include?(key) end end diff --git a/lib/gitlab/import_export/reader.rb b/lib/gitlab/import_export/reader.rb index a1e7159fe42..eb7f5120592 100644 --- a/lib/gitlab/import_export/reader.rb +++ b/lib/gitlab/import_export/reader.rb @@ -15,7 +15,10 @@ module Gitlab # Outputs a hash in the format described here: http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html # for outputting a project in JSON format, including its relations and sub relations. def project_tree - @attributes_finder.find_included(:project).merge(include: build_hash(@tree)) + attributes = @attributes_finder.find(:project) + project_attributes = attributes.is_a?(Hash) ? attributes[:project] : {} + + project_attributes.merge(include: build_hash(@tree)) rescue => e @shared.error(e) false diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 4a54e7ef2e7..956763fa399 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -15,7 +15,7 @@ module Gitlab priorities: :label_priorities, label: :project_label }.freeze - USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id merge_user_id resolved_by_id].freeze + USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb index 62239779454..8827507955d 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus.rb @@ -50,6 +50,12 @@ module Gitlab def get(url) handle_response(HTTParty.get(url)) + rescue SocketError + raise PrometheusError, "Can't connect to #{url}" + rescue OpenSSL::SSL::SSLError + raise PrometheusError, "#{url} contains invalid SSL data" + rescue HTTParty::Error + raise PrometheusError, "Network connection error" end def handle_response(response) diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 08b061d5e31..b7fef5dd068 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -22,6 +22,10 @@ module Gitlab @namespace_regex ||= /\A#{NAMESPACE_REGEX_STR}\z/.freeze end + def full_namespace_regex + @full_namespace_regex ||= %r{\A#{FULL_NAMESPACE_REGEX_STR}\z} + end + def namespace_route_regex @namespace_route_regex ||= /#{NAMESPACE_REGEX_STR}/.freeze end diff --git a/lib/gitlab/repo_path.rb b/lib/gitlab/repo_path.rb index 4b1d828c45c..878e03f61d7 100644 --- a/lib/gitlab/repo_path.rb +++ b/lib/gitlab/repo_path.rb @@ -2,18 +2,29 @@ module Gitlab module RepoPath NotFoundError = Class.new(StandardError) - def self.strip_storage_path(repo_path) - result = nil + def self.parse(repo_path) + project_path = strip_storage_path(repo_path.sub(/\.git\z/, ''), fail_on_not_found: false) + project = Project.find_by_full_path(project_path) + if project_path.end_with?('.wiki') && !project + project = Project.find_by_full_path(project_path.chomp('.wiki')) + wiki = true + else + wiki = false + end + + [project, wiki] + end + + def self.strip_storage_path(repo_path, fail_on_not_found: true) + result = repo_path - Gitlab.config.repositories.storages.values.each do |params| - storage_path = params['path'] - if repo_path.start_with?(storage_path) - result = repo_path.sub(storage_path, '') - break - end + storage = Gitlab.config.repositories.storages.values.find do |params| + repo_path.start_with?(params['path']) end - if result.nil? + if storage + result = result.sub(storage['path'], '') + elsif fail_on_not_found raise NotFoundError.new("No known storage path matches #{repo_path.inspect}") end diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index 36a871e5bbc..b1d6ea665b7 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -83,7 +83,7 @@ module Gitlab # Timeout should be less than 900 ideally, to prevent the memory killer # to silently kill the process without knowing we are timing out here. output, status = Popen.popen([gitlab_shell_projects_path, 'import-project', - storage, "#{name}.git", url, '800']) + storage, "#{name}.git", url, "#{Gitlab.config.gitlab_shell.git_timeout}"]) raise Error, output unless status.zero? true end @@ -99,7 +99,7 @@ module Gitlab # fetch_remote("gitlab/gitlab-ci", "upstream") # def fetch_remote(storage, name, remote, forced: false, no_tags: false) - args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, '800'] + args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, "#{Gitlab.config.gitlab_shell.git_timeout}"] args << '--force' if forced args << '--no-tags' if no_tags diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb index 60d35be2599..12a385f90fd 100644 --- a/lib/gitlab/slash_commands/command_definition.rb +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -1,16 +1,19 @@ module Gitlab module SlashCommands class CommandDefinition - attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block + attr_accessor :name, :aliases, :description, :explanation, :params, + :condition_block, :parse_params_block, :action_block def initialize(name, attributes = {}) @name = name - @aliases = attributes[:aliases] || [] - @description = attributes[:description] || '' - @params = attributes[:params] || [] + @aliases = attributes[:aliases] || [] + @description = attributes[:description] || '' + @explanation = attributes[:explanation] || '' + @params = attributes[:params] || [] @condition_block = attributes[:condition_block] - @action_block = attributes[:action_block] + @parse_params_block = attributes[:parse_params_block] + @action_block = attributes[:action_block] end def all_names @@ -28,14 +31,20 @@ module Gitlab context.instance_exec(&condition_block) end + def explain(context, opts, arg) + return unless available?(opts) + + if explanation.respond_to?(:call) + execute_block(explanation, context, arg) + else + explanation + end + end + def execute(context, opts, arg) return if noop? || !available?(opts) - if arg.present? - context.instance_exec(arg, &action_block) - elsif action_block.arity == 0 - context.instance_exec(&action_block) - end + execute_block(action_block, context, arg) end def to_h(opts) @@ -52,6 +61,23 @@ module Gitlab params: params } end + + private + + def execute_block(block, context, arg) + if arg.present? + parsed = parse_params(arg, context) + context.instance_exec(parsed, &block) + elsif block.arity == 0 + context.instance_exec(&block) + end + end + + def parse_params(arg, context) + return arg unless parse_params_block + + context.instance_exec(arg, &parse_params_block) + end end end end diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index 50b0937d267..614bafbe1b2 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -44,6 +44,22 @@ module Gitlab @params = params end + # Allows to give an explanation of what the command will do when + # executed. This explanation is shown when rendering the Markdown + # preview. + # + # Example: + # + # explanation do |arguments| + # "Adds label(s) #{arguments.join(' ')}" + # end + # command :command_key do |arguments| + # # Awesome code block + # end + def explanation(text = '', &block) + @explanation = block_given? ? block : text + end + # Allows to define conditions that must be met in order for the command # to be returned by `.command_names` & `.command_definitions`. # It accepts a block that will be evaluated with the context given to @@ -61,6 +77,24 @@ module Gitlab @condition_block = block end + # Allows to perform initial parsing of parameters. The result is passed + # both to `command` and `explanation` blocks, instead of the raw + # parameters. + # It accepts a block that will be evaluated with the context given to + # `CommandDefintion#to_h`. + # + # Example: + # + # parse_params do |raw| + # raw.strip + # end + # command :command_key do |parsed| + # # Awesome code block + # end + def parse_params(&block) + @parse_params_block = block + end + # Registers a new command which is recognizeable from body of email or # comment. # It accepts aliases and takes a block. @@ -75,11 +109,13 @@ module Gitlab definition = CommandDefinition.new( name, - aliases: aliases, - description: @description, - params: @params, - condition_block: @condition_block, - action_block: block + aliases: aliases, + description: @description, + explanation: @explanation, + params: @params, + condition_block: @condition_block, + parse_params_block: @parse_params_block, + action_block: block ) self.command_definitions << definition @@ -89,8 +125,14 @@ module Gitlab end @description = nil + @explanation = nil @params = nil @condition_block = nil + @parse_params_block = nil + end + + def definition_by_name(name) + command_definitions_by_name[name.to_sym] end end end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index c551f939df1..8c5ad01e8c2 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -16,15 +16,17 @@ module Gitlab SECRET_LENGTH = 32 class << self - def git_http_ok(repository, user, action) + def git_http_ok(repository, is_wiki, user, action) + project = repository.project repo_path = repository.path_to_repo params = { GL_ID: Gitlab::GlId.gl_id(user), + GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki), RepoPath: repo_path, } if Gitlab.config.gitaly.enabled - address = Gitlab::GitalyClient.get_address(repository.project.repository_storage) + address = Gitlab::GitalyClient.get_address(project.repository_storage) params[:Repository] = repository.gitaly_repository.to_h feature_enabled = case action.to_s diff --git a/lib/tasks/gemojione.rake b/lib/tasks/gemojione.rake index 5293f5af12d..b5572a39d30 100644 --- a/lib/tasks/gemojione.rake +++ b/lib/tasks/gemojione.rake @@ -19,6 +19,7 @@ namespace :gemojione do entry = { category: emoji_hash['category'], moji: emoji_hash['moji'], + description: emoji_hash['description'], unicodeVersion: Gitlab::Emoji.emoji_unicode_version(name), digest: hash_digest, } diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake new file mode 100644 index 00000000000..0aa21a4bd13 --- /dev/null +++ b/lib/tasks/gettext.rake @@ -0,0 +1,14 @@ +require "gettext_i18n_rails/tasks" + +namespace :gettext do + # Customize list of translatable files + # See: https://github.com/grosser/gettext_i18n_rails#customizing-list-of-translatable-files + def files_to_translate + folders = %W(app lib config #{locale_path}).join(',') + exts = %w(rb erb haml slim rhtml js jsx vue coffee handlebars hbs mustache).join(',') + + Dir.glob( + "{#{folders}}/**/*.{#{exts}}" + ) + end +end diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake index 5476438b8fa..139ab70e125 100644 --- a/lib/tasks/gitlab/db.rake +++ b/lib/tasks/gitlab/db.rake @@ -65,6 +65,7 @@ namespace :gitlab do migrations = `git diff #{ref}.. --diff-filter=A --name-only -- db/migrate`.lines .map { |file| Rails.root.join(file.strip).to_s } .select { |file| File.file?(file) } + .select { |file| /\A[0-9]+.*\.rb\z/ =~ File.basename(file) } Gitlab::DowntimeCheck.new.check_and_print(migrations) end diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake index 046780481ba..3c5bc0146a1 100644 --- a/lib/tasks/gitlab/gitaly.rake +++ b/lib/tasks/gitlab/gitaly.rake @@ -1,18 +1,18 @@ namespace :gitlab do namespace :gitaly do desc "GitLab | Install or upgrade gitaly" - task :install, [:dir] => :environment do |t, args| + task :install, [:dir, :repo] => :environment do |t, args| require 'toml' warn_user_is_not_gitlab unless args.dir.present? abort %(Please specify the directory where you want to install gitaly:\n rake "gitlab:gitaly:install[/home/git/gitaly]") end + args.with_defaults(repo: 'https://gitlab.com/gitlab-org/gitaly.git') version = Gitlab::GitalyClient.expected_server_version - repo = 'https://gitlab.com/gitlab-org/gitaly.git' - checkout_or_clone_version(version: version, repo: repo, target_dir: args.dir) + checkout_or_clone_version(version: version, repo: args.repo, target_dir: args.dir) _, status = Gitlab::Popen.popen(%w[which gmake]) command = status.zero? ? 'gmake' : 'make' diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake index 95687066819..ee2cdcdea1b 100644 --- a/lib/tasks/gitlab/shell.rake +++ b/lib/tasks/gitlab/shell.rake @@ -41,8 +41,14 @@ namespace :gitlab do # Generate config.yml based on existing gitlab settings File.open("config.yml", "w+") {|f| f.puts config.to_yaml} - # Launch installation process - system(*%w(bin/install) + repository_storage_paths_args) + [ + %w(bin/install) + repository_storage_paths_args, + %w(bin/compile) + ].each do |cmd| + unless Kernel.system(*cmd) + raise "command failed: #{cmd.join(' ')}" + end + end end # (Re)create hooks diff --git a/lib/tasks/gitlab/workhorse.rake b/lib/tasks/gitlab/workhorse.rake index a00b02188cf..e7ac0b5859f 100644 --- a/lib/tasks/gitlab/workhorse.rake +++ b/lib/tasks/gitlab/workhorse.rake @@ -1,16 +1,16 @@ namespace :gitlab do namespace :workhorse do desc "GitLab | Install or upgrade gitlab-workhorse" - task :install, [:dir] => :environment do |t, args| + task :install, [:dir, :repo] => :environment do |t, args| warn_user_is_not_gitlab unless args.dir.present? abort %(Please specify the directory where you want to install gitlab-workhorse:\n rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]") end + args.with_defaults(repo: 'https://gitlab.com/gitlab-org/gitlab-workhorse.git') version = Gitlab::Workhorse.version - repo = 'https://gitlab.com/gitlab-org/gitlab-workhorse.git' - checkout_or_clone_version(version: version, repo: repo, target_dir: args.dir) + checkout_or_clone_version(version: version, repo: args.repo, target_dir: args.dir) _, status = Gitlab::Popen.popen(%w[which gmake]) command = status.zero? ? 'gmake' : 'make' diff --git a/lib/tasks/migrate/add_limits_mysql.rake b/lib/tasks/migrate/add_limits_mysql.rake index 6ded519aff2..761f275d42a 100644 --- a/lib/tasks/migrate/add_limits_mysql.rake +++ b/lib/tasks/migrate/add_limits_mysql.rake @@ -1,7 +1,9 @@ require Rails.root.join('db/migrate/limits_to_mysql') +require Rails.root.join('db/migrate/markdown_cache_limits_to_mysql') desc "GitLab | Add limits to strings in mysql database" task add_limits_mysql: :environment do puts "Adding limits to schema.rb for mysql" LimitsToMysql.new.up + MarkdownCacheLimitsToMysql.new.up end diff --git a/lib/tasks/migrate/setup_postgresql.rake b/lib/tasks/migrate/setup_postgresql.rake index 8938bc515f5..1e00b47303d 100644 --- a/lib/tasks/migrate/setup_postgresql.rake +++ b/lib/tasks/migrate/setup_postgresql.rake @@ -11,4 +11,5 @@ task setup_postgresql: :environment do AddUsersLowerUsernameEmailIndexes.new.up AddLowerPathIndexToRoutes.new.up IndexRoutesPathForLike.new.up + IndexRedirectRoutesPathForLike.new.up end diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po new file mode 100644 index 00000000000..b804dc0436f --- /dev/null +++ b/locale/de/gitlab.po @@ -0,0 +1,207 @@ +# German translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-04-12 22:37-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: German\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" + +msgid "ByAuthor|by" +msgstr "" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + +msgid "CycleAnalyticsStage|Code" +msgstr "" + +msgid "CycleAnalyticsStage|Issue" +msgstr "" + +msgid "CycleAnalyticsStage|Plan" +msgstr "" + +msgid "CycleAnalyticsStage|Production" +msgstr "" + +msgid "CycleAnalyticsStage|Review" +msgstr "" + +msgid "CycleAnalyticsStage|Staging" +msgstr "" + +msgid "CycleAnalyticsStage|Test" +msgstr "" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" +msgstr "" + +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" + +msgid "Median" +msgstr "" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" + +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "OpenedNDaysAgo|Opened" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "ProjectLifecycle|Stage" +msgstr "" + +msgid "Read more" +msgstr "" + +msgid "Related Commits" +msgstr "" + +msgid "Related Deployed Jobs" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Jobs" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Related Merged Requests" +msgstr "" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "" + +msgid "The phase of the development lifecycle." +msgstr "" + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "" + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "" + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "" + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "" + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + +msgid "Total Time" +msgstr "" + +msgid "Total test time for all commits/merges" +msgstr "" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "" + +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" diff --git a/app/views/snippets/notes/_edit.html.haml b/locale/de/gitlab.po.time_stamp index e69de29bb2d..e69de29bb2d 100644 --- a/app/views/snippets/notes/_edit.html.haml +++ b/locale/de/gitlab.po.time_stamp diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po new file mode 100644 index 00000000000..a43bafbbe28 --- /dev/null +++ b/locale/en/gitlab.po @@ -0,0 +1,207 @@ +# English translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-04-12 22:36-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: English\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" + +msgid "ByAuthor|by" +msgstr "" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + +msgid "CycleAnalyticsStage|Code" +msgstr "" + +msgid "CycleAnalyticsStage|Issue" +msgstr "" + +msgid "CycleAnalyticsStage|Plan" +msgstr "" + +msgid "CycleAnalyticsStage|Production" +msgstr "" + +msgid "CycleAnalyticsStage|Review" +msgstr "" + +msgid "CycleAnalyticsStage|Staging" +msgstr "" + +msgid "CycleAnalyticsStage|Test" +msgstr "" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" +msgstr "" + +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" + +msgid "Median" +msgstr "" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" + +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "OpenedNDaysAgo|Opened" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "ProjectLifecycle|Stage" +msgstr "" + +msgid "Read more" +msgstr "" + +msgid "Related Commits" +msgstr "" + +msgid "Related Deployed Jobs" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Jobs" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Related Merged Requests" +msgstr "" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "" + +msgid "The phase of the development lifecycle." +msgstr "" + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "" + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "" + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "" + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "" + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + +msgid "Total Time" +msgstr "" + +msgid "Total test time for all commits/merges" +msgstr "" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "" + +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" diff --git a/locale/en/gitlab.po.time_stamp b/locale/en/gitlab.po.time_stamp new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/locale/en/gitlab.po.time_stamp diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po new file mode 100644 index 00000000000..c14ddd3b94c --- /dev/null +++ b/locale/es/gitlab.po @@ -0,0 +1,208 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.1\n" + +msgid "ByAuthor|by" +msgstr "por" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "Cambio" +msgstr[1] "Cambios" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." + +msgid "CycleAnalyticsStage|Code" +msgstr "Código" + +msgid "CycleAnalyticsStage|Issue" +msgstr "Incidencia" + +msgid "CycleAnalyticsStage|Plan" +msgstr "Planificación" + +msgid "CycleAnalyticsStage|Production" +msgstr "Producción" + +msgid "CycleAnalyticsStage|Review" +msgstr "Revisión" + +#, fuzzy +msgid "CycleAnalyticsStage|Staging" +msgstr "Puesta en escena" + +msgid "CycleAnalyticsStage|Test" +msgstr "Pruebas" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "Despliegue" +msgstr[1] "Despliegues" + +msgid "FirstPushedBy|First" +msgstr "Primer" + +msgid "FirstPushedBy|pushed by" +msgstr "enviado por" + +msgid "From issue creation until deploy to production" +msgstr "Desde la creación de la incidencia hasta el despliegue a producción" + +msgid "From merge request merge until deploy to production" +msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción" + +msgid "Introducing Cycle Analytics" +msgstr "Introducción a Cycle Analytics" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "Último %d día" +msgstr[1] "Últimos %d días" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "Limitado a mostrar máximo %d evento" +msgstr[1] "Limitado a mostrar máximo %d eventos" + +msgid "Median" +msgstr "Mediana" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "Nueva incidencia" +msgstr[1] "Nuevas incidencias" + +msgid "Not available" +msgstr "No disponible" + +msgid "Not enough data" +msgstr "No hay suficientes datos" + +msgid "OpenedNDaysAgo|Opened" +msgstr "Abierto" + +msgid "Pipeline Health" +msgstr "Estado del Pipeline" + +msgid "ProjectLifecycle|Stage" +msgstr "Etapa" + +msgid "Read more" +msgstr "Leer más" + +msgid "Related Commits" +msgstr "Cambios Relacionados" + +msgid "Related Deployed Jobs" +msgstr "Trabajos Desplegados Relacionados" + +msgid "Related Issues" +msgstr "Incidencias Relacionadas" + +msgid "Related Jobs" +msgstr "Trabajos Relacionados" + +msgid "Related Merge Requests" +msgstr "Solicitudes de fusión Relacionadas" + +msgid "Related Merged Requests" +msgstr "Solicitudes de fusión Relacionadas" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "Mostrando %d evento" +msgstr[1] "Mostrando %d eventos" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión." + +msgid "The collection of events added to the data gathered for that stage." +msgstr "La colección de eventos agregados a los datos recopilados para esa etapa." + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." + +msgid "The phase of the development lifecycle." +msgstr "La etapa del ciclo de vida de desarrollo." + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio." + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez." + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse." + +msgid "The time taken by each data entry gathered by that stage." +msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa." + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6." + +msgid "Time before an issue gets scheduled" +msgstr "Tiempo antes de que una incidencia sea programada" + +msgid "Time before an issue starts implementation" +msgstr "Tiempo antes de que empieze la implementación de una incidencia" + +msgid "Time between merge request creation and merge/close" +msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta" + +msgid "Time until first merge request" +msgstr "Tiempo hasta la primera solicitud de fusión" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "hr" +msgstr[1] "hrs" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "min" +msgstr[1] "mins" + +msgid "Time|s" +msgstr "s" + +msgid "Total Time" +msgstr "Tiempo Total" + +msgid "Total test time for all commits/merges" +msgstr "Tiempo total de pruebas para todos los cambios o integraciones" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador." + +msgid "We don't have enough data to show this stage." +msgstr "No hay suficientes datos para mostrar en esta etapa." + +msgid "You need permission." +msgstr "Necesitas permisos." + +msgid "day" +msgid_plural "days" +msgstr[0] "día" +msgstr[1] "días" diff --git a/locale/es/gitlab.po.time_stamp b/locale/es/gitlab.po.time_stamp new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/locale/es/gitlab.po.time_stamp diff --git a/locale/gitlab.pot b/locale/gitlab.pot new file mode 100644 index 00000000000..3967d40ea9e --- /dev/null +++ b/locale/gitlab.pot @@ -0,0 +1,208 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +msgid "ByAuthor|by" +msgstr "" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + +msgid "CycleAnalyticsStage|Code" +msgstr "" + +msgid "CycleAnalyticsStage|Issue" +msgstr "" + +msgid "CycleAnalyticsStage|Plan" +msgstr "" + +msgid "CycleAnalyticsStage|Production" +msgstr "" + +msgid "CycleAnalyticsStage|Review" +msgstr "" + +msgid "CycleAnalyticsStage|Staging" +msgstr "" + +msgid "CycleAnalyticsStage|Test" +msgstr "" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" +msgstr "" + +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" + +msgid "Median" +msgstr "" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" + +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "OpenedNDaysAgo|Opened" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "ProjectLifecycle|Stage" +msgstr "" + +msgid "Read more" +msgstr "" + +msgid "Related Commits" +msgstr "" + +msgid "Related Deployed Jobs" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Jobs" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Related Merged Requests" +msgstr "" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "" + +msgid "The phase of the development lifecycle." +msgstr "" + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "" + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "" + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "" + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "" + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + +msgid "Total Time" +msgstr "" + +msgid "Total test time for all commits/merges" +msgstr "" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "" + +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" diff --git a/package.json b/package.json index 9ed5e1a7475..800327d8a08 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "dropzone": "^4.2.0", "emoji-unicode-version": "^0.2.1", "eslint-plugin-html": "^2.0.1", + "exports-loader": "^0.6.4", "file-loader": "^0.11.1", + "jed": "^1.1.1", "jquery": "^2.2.1", "jquery-ujs": "^1.2.1", "js-cookie": "^2.1.3", @@ -38,9 +40,11 @@ "pikaday": "^1.5.1", "prismjs": "^1.6.0", "raphael": "^2.2.7", + "raven-js": "^3.14.0", "raw-loader": "^0.5.1", "react-dev-utils": "^0.5.2", "select2": "3.5.2-browserify", + "sql.js": "^0.4.0", "stats-webpack-plugin": "^0.4.3", "three": "^0.84.0", "three-orbit-controls": "^82.1.0", diff --git a/scripts/static-analysis b/scripts/static-analysis index 192d9d4c3ba..7dc8f679036 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -3,13 +3,13 @@ require ::File.expand_path('../lib/gitlab/popen', __dir__) tasks = [ + %w[bundle exec bundle-audit check --update --ignore CVE-2016-4658], %w[bundle exec rake config_lint], %w[bundle exec rake flay], %w[bundle exec rake haml_lint], %w[bundle exec rake scss_lint], %w[bundle exec rake brakeman], %w[bundle exec license_finder], - %w[scripts/lint-doc.sh], %w[yarn run eslint], %w[bundle exec rubocop --require rubocop-rspec] ] diff --git a/spec/controllers/admin/services_controller_spec.rb b/spec/controllers/admin/services_controller_spec.rb index e5cdd52307e..c94616d8508 100644 --- a/spec/controllers/admin/services_controller_spec.rb +++ b/spec/controllers/admin/services_controller_spec.rb @@ -23,4 +23,36 @@ describe Admin::ServicesController do end end end + + describe "#update" do + let(:project) { create(:empty_project) } + let!(:service) do + RedmineService.create( + project: project, + active: false, + template: true, + properties: { + project_url: 'http://abc', + issues_url: 'http://abc', + new_issue_url: 'http://abc' + } + ) + end + + it 'calls the propagation worker when service is active' do + expect(PropagateServiceTemplateWorker).to receive(:perform_async).with(service.id) + + put :update, id: service.id, service: { active: true } + + expect(response).to have_http_status(302) + end + + it 'does not call the propagation worker when service is not active' do + expect(PropagateServiceTemplateWorker).not_to receive(:perform_async) + + put :update, id: service.id, service: { properties: {} } + + expect(response).to have_http_status(302) + end + end end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 760f33b09c1..d40aae04fc3 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -4,7 +4,7 @@ describe ApplicationController do let(:user) { create(:user) } describe '#check_password_expiration' do - let(:controller) { ApplicationController.new } + let(:controller) { described_class.new } it 'redirects if the user is over their password expiry' do user.password_expires_at = Time.new(2002) @@ -34,7 +34,7 @@ describe ApplicationController do describe "#authenticate_user_from_token!" do describe "authenticating a user from a private token" do - controller(ApplicationController) do + controller(described_class) do def index render text: "authenticated" end @@ -66,7 +66,7 @@ describe ApplicationController do end describe "authenticating a user from a personal access token" do - controller(ApplicationController) do + controller(described_class) do def index render text: 'authenticated' end @@ -106,16 +106,15 @@ describe ApplicationController do controller.send(:route_not_found) end - it 'does redirect to login page if not authenticated' do + it 'does redirect to login page via authenticate_user! if not authenticated' do allow(controller).to receive(:current_user).and_return(nil) - expect(controller).to receive(:redirect_to) - expect(controller).to receive(:new_user_session_path) + expect(controller).to receive(:authenticate_user!) controller.send(:route_not_found) end end context 'two-factor authentication' do - let(:controller) { ApplicationController.new } + let(:controller) { described_class.new } describe '#check_two_factor_requirement' do subject { controller.send :check_two_factor_requirement } diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb index 762e90f4a16..085f3fd8543 100644 --- a/spec/controllers/dashboard/todos_controller_spec.rb +++ b/spec/controllers/dashboard/todos_controller_spec.rb @@ -14,7 +14,7 @@ describe Dashboard::TodosController do describe 'GET #index' do context 'when using pagination' do let(:last_page) { user.todos.page.total_pages } - let!(:issues) { create_list(:issue, 2, project: project, assignee: user) } + let!(:issues) { create_list(:issue, 2, project: project, assignees: [user]) } before do issues.each { |issue| todo_service.new_issue(issue, user) } diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb index 6e4b5f78e33..7cf2996ffd0 100644 --- a/spec/controllers/groups/milestones_controller_spec.rb +++ b/spec/controllers/groups/milestones_controller_spec.rb @@ -6,6 +6,16 @@ describe Groups::MilestonesController do let(:project2) { create(:empty_project, group: group) } let(:user) { create(:user) } let(:title) { '肯定不是中文的问题' } + let(:milestone) do + project_milestone = create(:milestone, project: project) + + GroupMilestone.build( + group, + [project], + project_milestone.title + ) + end + let(:milestone_path) { group_milestone_path(group, milestone.safe_title, title: milestone.title) } before do sign_in(user) @@ -14,6 +24,8 @@ describe Groups::MilestonesController do controller.instance_variable_set(:@group, group) end + it_behaves_like 'milestone tabs' + describe "#create" do it "creates group milestone with Chinese title" do post :create, diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index cad82a34fb0..073b87a1cb4 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -49,6 +49,26 @@ describe GroupsController do expect(assigns(:issues)).to eq [issue_2, issue_1] end end + + context 'when requesting the canonical path with different casing' do + it 'redirects to the correct casing' do + get :issues, id: group.to_param.upcase + + expect(response).to redirect_to(issues_group_path(group.to_param)) + expect(controller).not_to set_flash[:notice] + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + + it 'redirects to the canonical path' do + get :issues, id: redirect_route.path + + expect(response).to redirect_to(issues_group_path(group.to_param)) + expect(controller).to set_flash[:notice].to(/moved/) + end + end end describe 'GET #merge_requests' do @@ -74,6 +94,26 @@ describe GroupsController do expect(assigns(:merge_requests)).to eq [merge_request_2, merge_request_1] end end + + context 'when requesting the canonical path with different casing' do + it 'redirects to the correct casing' do + get :merge_requests, id: group.to_param.upcase + + expect(response).to redirect_to(merge_requests_group_path(group.to_param)) + expect(controller).not_to set_flash[:notice] + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + + it 'redirects to the canonical path' do + get :merge_requests, id: redirect_route.path + + expect(response).to redirect_to(merge_requests_group_path(group.to_param)) + expect(controller).to set_flash[:notice].to(/moved/) + end + end end describe 'DELETE #destroy' do @@ -81,7 +121,7 @@ describe GroupsController do it 'returns 404' do sign_in(create(:user)) - delete :destroy, id: group.path + delete :destroy, id: group.to_param expect(response.status).to eq(404) end @@ -94,15 +134,39 @@ describe GroupsController do it 'schedules a group destroy' do Sidekiq::Testing.fake! do - expect { delete :destroy, id: group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + expect { delete :destroy, id: group.to_param }.to change(GroupDestroyWorker.jobs, :size).by(1) end end it 'redirects to the root path' do - delete :destroy, id: group.path + delete :destroy, id: group.to_param expect(response).to redirect_to(root_path) end + + context 'when requesting the canonical path with different casing' do + it 'does not 404' do + delete :destroy, id: group.to_param.upcase + + expect(response).not_to have_http_status(404) + end + + it 'does not redirect to the correct casing' do + delete :destroy, id: group.to_param.upcase + + expect(response).not_to redirect_to(group_path(group.to_param)) + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + + it 'returns not found' do + delete :destroy, id: redirect_route.path + + expect(response).to have_http_status(404) + end + end end end @@ -111,7 +175,7 @@ describe GroupsController do sign_in(user) end - it 'updates the path succesfully' do + it 'updates the path successfully' do post :update, id: group.to_param, group: { path: 'new_path' } expect(response).to have_http_status(302) @@ -125,5 +189,29 @@ describe GroupsController do expect(assigns(:group).errors).not_to be_empty expect(assigns(:group).path).not_to eq('new_path') end + + context 'when requesting the canonical path with different casing' do + it 'does not 404' do + post :update, id: group.to_param.upcase, group: { path: 'new_path' } + + expect(response).not_to have_http_status(404) + end + + it 'does not redirect to the correct casing' do + post :update, id: group.to_param.upcase, group: { path: 'new_path' } + + expect(response).not_to redirect_to(group_path(group.to_param)) + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + + it 'returns not found' do + post :update, id: redirect_route.path, group: { path: 'new_path' } + + expect(response).to have_http_status(404) + end + end end end diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb new file mode 100644 index 00000000000..eff9fab8da2 --- /dev/null +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Projects::ArtifactsController do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit.sha, + ref: project.default_branch, + status: 'success') + end + + let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + + before do + project.team << [user, :developer] + + sign_in(user) + end + + describe 'GET download' do + it 'sends the artifacts file' do + expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original + + get :download, namespace_id: project.namespace, project_id: project, build_id: build + end + end + + describe 'GET browse' do + context 'when the directory exists' do + it 'renders the browse view' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2' + + expect(response).to render_template('projects/artifacts/browse') + end + end + + context 'when the directory does not exist' do + it 'responds Not Found' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET file' do + context 'when the file exists' do + it 'renders the file view' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + expect(response).to render_template('projects/artifacts/file') + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET raw' do + context 'when the file exists' do + it 'serves the file using workhorse' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] + + expect(send_data).to start_with('artifacts-entry:') + + base64_params = send_data.sub(/\Aartifacts\-entry:/, '') + params = JSON.parse(Base64.urlsafe_decode64(base64_params)) + + expect(params.keys).to eq(%w(Archive Entry)) + expect(params['Archive']).to end_with('build_artifacts.zip') + expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET latest_succeeded' do + def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse') + { + namespace_id: project.namespace, + project_id: project, + ref_name_and_path: File.join(ref, path), + job: job + } + end + + context 'cannot find the build' do + shared_examples 'not found' do + it { expect(response).to have_http_status(:not_found) } + end + + context 'has no such ref' do + before do + get :latest_succeeded, params_from_ref('TAIL', build.name) + end + + it_behaves_like 'not found' + end + + context 'has no such build' do + before do + get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD') + end + + it_behaves_like 'not found' + end + + context 'has no path' do + before do + get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '') + end + + it_behaves_like 'not found' + end + end + + context 'found the build and redirect' do + shared_examples 'redirect to the build' do + it 'redirects' do + path = browse_namespace_project_build_artifacts_path( + project.namespace, + project, + build) + + expect(response).to redirect_to(path) + end + end + + context 'with regular branch' do + before do + pipeline.update(ref: 'master', + sha: project.commit('master').sha) + + get :latest_succeeded, params_from_ref('master') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name containing slash' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name and path containing slashes' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md') + end + + it 'redirects' do + path = file_namespace_project_build_artifacts_path( + project.namespace, + project, + build, + 'README.md') + + expect(response).to redirect_to(path) + end + end + end + end +end diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb index 15667e8d4b1..dc3b72c6de4 100644 --- a/spec/controllers/projects/boards/issues_controller_spec.rb +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -34,7 +34,7 @@ describe Projects::Boards::IssuesController do issue = create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow) - create(:labeled_issue, project: project, labels: [development], assignee: johndoe) + create(:labeled_issue, project: project, labels: [development], assignees: [johndoe]) issue.subscribe(johndoe, project) list_issues user: user, board: board, list: list2 diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index d20e7368086..8f915d9d210 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -14,7 +14,7 @@ describe Projects::BranchesController do controller.instance_variable_set(:@project, project) end - describe "POST create" do + describe "POST create with HTML format" do render_views context "on creation of a new branch" do @@ -152,6 +152,42 @@ describe Projects::BranchesController do end end + describe 'POST create with JSON format' do + before do + sign_in(user) + end + + context 'with valid params' do + it 'returns a successful 200 response' do + create_branch name: 'my-branch', ref: 'master' + + expect(response).to have_http_status(200) + end + + it 'returns the created branch' do + create_branch name: 'my-branch', ref: 'master' + + expect(response).to match_response_schema('branch') + end + end + + context 'with invalid params' do + it 'returns an unprocessable entity 422 response' do + create_branch name: "<script>alert('merge');</script>", ref: "<script>alert('ref');</script>" + + expect(response).to have_http_status(422) + end + end + + def create_branch(name:, ref:) + post :create, namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: name, + ref: ref, + format: :json + end + end + describe "POST destroy with HTML format" do render_views diff --git a/spec/controllers/projects/deploy_keys_controller_spec.rb b/spec/controllers/projects/deploy_keys_controller_spec.rb new file mode 100644 index 00000000000..efe1a78415b --- /dev/null +++ b/spec/controllers/projects/deploy_keys_controller_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe Projects::DeployKeysController do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + project.team << [user, :master] + + sign_in(user) + end + + describe 'GET index' do + let(:params) do + { namespace_id: project.namespace, project_id: project } + end + + context 'when html requested' do + it 'redirects to blob' do + get :index, params + + expect(response).to redirect_to(namespace_project_settings_repository_path(params)) + end + end + + context 'when json requested' do + let(:project2) { create(:empty_project, :internal)} + let(:project_private) { create(:empty_project, :private)} + + let(:deploy_key_internal) do + create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com') + end + let(:deploy_key_actual) do + create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') + end + let!(:deploy_key_public) { create(:deploy_key, public: true) } + + let!(:deploy_keys_project_internal) do + create(:deploy_keys_project, project: project2, deploy_key: deploy_key_internal) + end + + let!(:deploy_keys_actual_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key_actual) + end + + let!(:deploy_keys_project_private) do + create(:deploy_keys_project, project: project_private, deploy_key: create(:another_deploy_key)) + end + + before do + project2.team << [user, :developer] + end + + it 'returns json in a correct format' do + get :index, params.merge(format: :json) + + json = JSON.parse(response.body) + + expect(json.keys).to match_array(%w(enabled_keys available_project_keys public_keys)) + expect(json['enabled_keys'].count).to eq(1) + expect(json['available_project_keys'].count).to eq(1) + expect(json['public_keys'].count).to eq(1) + end + end + end +end diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb new file mode 100644 index 00000000000..89692b601b2 --- /dev/null +++ b/spec/controllers/projects/deployments_controller_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Projects::DeploymentsController do + include ApiHelpers + + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:environment) { create(:environment, name: 'production', project: project) } + + before do + project.add_master(user) + + sign_in(user) + end + + describe 'GET #index' do + it 'returns list of deployments from last 8 hours' do + create(:deployment, environment: environment, created_at: 9.hours.ago) + create(:deployment, environment: environment, created_at: 7.hours.ago) + create(:deployment, environment: environment) + + get :index, environment_params(after: 8.hours.ago) + + expect(response).to be_ok + + expect(json_response['deployments'].count).to eq(2) + end + + it 'returns a list with deployments information' do + create(:deployment, environment: environment) + + get :index, environment_params + + expect(response).to be_ok + expect(response).to match_response_schema('deployments') + end + end + + def environment_params(opts = {}) + opts.reverse_merge(namespace_id: project.namespace, project_id: project, environment_id: environment.id) + end +end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 79034b8d24d..1f79e72495a 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -173,12 +173,12 @@ describe Projects::IssuesController do namespace_id: project.namespace.to_param, project_id: project, id: issue.iid, - issue: { assignee_id: assignee.id }, + issue: { assignee_ids: [assignee.id] }, format: :json body = JSON.parse(response.body) - expect(body['assignee'].keys) - .to match_array(%w(name username avatar_url)) + expect(body['assignees'].first.keys) + .to match_array(%w(id name username avatar_url)) end end @@ -348,7 +348,7 @@ describe Projects::IssuesController do let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project) } let!(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) } - let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignee: assignee) } + let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignees: [assignee]) } describe 'GET #index' do it 'does not list confidential issues for guests' do @@ -756,4 +756,28 @@ describe Projects::IssuesController do expect(response).to have_http_status(200) end end + + describe 'POST create_merge_request' do + before do + project.add_developer(user) + sign_in(user) + end + + it 'creates a new merge request' do + expect { create_merge_request }.to change(project.merge_requests, :count).by(1) + end + + it 'render merge request as json' do + create_merge_request + + expect(response).to match_response_schema('merge_request') + end + + def create_merge_request + post :create_merge_request, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: issue.to_param, + format: :json + end + end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index a793da4162a..0483c6b7879 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -1067,7 +1067,7 @@ describe Projects::MergeRequestsController do end it 'correctly pluralizes flash message on success' do - issue2.update!(assignee: user) + issue2.assignees = [user] post_assign_issues diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 47e61c3cea8..84a61b2784e 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -7,6 +7,7 @@ describe Projects::MilestonesController do let(:issue) { create(:issue, project: project, milestone: milestone) } let!(:label) { create(:label, project: project, title: 'Issue Label', issues: [issue]) } let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) } + let(:milestone_path) { namespace_project_milestone_path } before do sign_in(user) @@ -14,6 +15,8 @@ describe Projects::MilestonesController do controller.instance_variable_set(:@project, project) end + it_behaves_like 'milestone tabs' + describe "#show" do render_views diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb new file mode 100644 index 00000000000..df35d8e86b9 --- /dev/null +++ b/spec/controllers/projects/pages_controller_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe Projects::PagesController do + let(:user) { create(:user) } + let(:project) { create(:empty_project, :public, :access_requestable) } + + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project + } + end + + before do + allow(Gitlab.config.pages).to receive(:enabled).and_return(true) + sign_in(user) + project.add_master(user) + end + + describe 'GET show' do + it 'returns 200 status' do + get :show, request_params + + expect(response).to have_http_status(200) + end + end + + describe 'DELETE destroy' do + it 'returns 302 status' do + delete :destroy, request_params + + expect(response).to have_http_status(302) + end + end + + context 'pages disabled' do + before do + allow(Gitlab.config.pages).to receive(:enabled).and_return(false) + end + + describe 'GET show' do + it 'returns 404 status' do + get :show, request_params + + expect(response).to have_http_status(404) + end + end + + describe 'DELETE destroy' do + it 'returns 404 status' do + delete :destroy, request_params + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb index 2362df895a8..33853c4b9d0 100644 --- a/spec/controllers/projects/pages_domains_controller_spec.rb +++ b/spec/controllers/projects/pages_domains_controller_spec.rb @@ -1,8 +1,9 @@ require 'spec_helper' describe Projects::PagesDomainsController do - let(:user) { create(:user) } - let(:project) { create(:project) } + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let!(:pages_domain) { create(:pages_domain, project: project) } let(:request_params) do { @@ -11,14 +12,17 @@ describe Projects::PagesDomainsController do } end + let(:pages_domain_params) do + build(:pages_domain, :with_certificate, :with_key, domain: 'my.otherdomain.com').slice(:key, :certificate, :domain) + end + before do + allow(Gitlab.config.pages).to receive(:enabled).and_return(true) sign_in(user) - project.team << [user, :master] + project.add_master(user) end describe 'GET show' do - let!(:pages_domain) { create(:pages_domain, project: project) } - it "displays the 'show' page" do get(:show, request_params.merge(id: pages_domain.domain)) @@ -37,10 +41,6 @@ describe Projects::PagesDomainsController do end describe 'POST create' do - let(:pages_domain_params) do - build(:pages_domain, :with_certificate, :with_key).slice(:key, :certificate, :domain) - end - it "creates a new pages domain" do expect do post(:create, request_params.merge(pages_domain: pages_domain_params)) @@ -51,8 +51,6 @@ describe Projects::PagesDomainsController do end describe 'DELETE destroy' do - let!(:pages_domain) { create(:pages_domain, project: project) } - it "deletes the pages domain" do expect do delete(:destroy, request_params.merge(id: pages_domain.domain)) @@ -61,4 +59,42 @@ describe Projects::PagesDomainsController do expect(response).to redirect_to(namespace_project_pages_path(project.namespace, project)) end end + + context 'pages disabled' do + before do + allow(Gitlab.config.pages).to receive(:enabled).and_return(false) + end + + describe 'GET show' do + it 'returns 404 status' do + get(:show, request_params.merge(id: pages_domain.domain)) + + expect(response).to have_http_status(404) + end + end + + describe 'GET new' do + it 'returns 404 status' do + get :new, request_params + + expect(response).to have_http_status(404) + end + end + + describe 'POST create' do + it "returns 404 status" do + post(:create, request_params.merge(pages_domain: pages_domain_params)) + + expect(response).to have_http_status(404) + end + end + + describe 'DELETE destroy' do + it "deletes the pages domain" do + delete(:destroy, request_params.merge(id: pages_domain.domain)) + + expect(response).to have_http_status(404) + end + end + end end diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index b9bacc5a64a..fb4a4721a58 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -1,10 +1,14 @@ require 'spec_helper' describe Projects::PipelinesController do + include ApiHelpers + let(:user) { create(:user) } let(:project) { create(:empty_project, :public) } before do + project.add_developer(user) + sign_in(user) end @@ -22,6 +26,7 @@ describe Projects::PipelinesController do it 'returns JSON with serialized pipelines' do expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline') expect(json_response).to include('pipelines') expect(json_response['pipelines'].count).to eq 4 @@ -32,6 +37,34 @@ describe Projects::PipelinesController do end end + describe 'GET show JSON' do + let!(:pipeline) { create(:ci_pipeline_with_one_job, project: project) } + + it 'returns the pipeline' do + get_pipeline_json + + expect(response).to have_http_status(:ok) + expect(json_response).not_to be_an(Array) + expect(json_response['id']).to be(pipeline.id) + expect(json_response['details']).to have_key 'stages' + end + + context 'when the pipeline has multiple jobs' do + it 'does not perform N + 1 queries' do + control_count = ActiveRecord::QueryRecorder.new { get_pipeline_json }.count + + create(:ci_build, pipeline: pipeline) + + # The plus 2 is needed to group and sort + expect { get_pipeline_json }.not_to exceed_query_limit(control_count + 2) + end + end + + def get_pipeline_json + get :show, namespace_id: project.namespace, project_id: project, id: pipeline, format: :json + end + end + describe 'GET stages.json' do let(:pipeline) { create(:ci_pipeline, project: project) } @@ -87,4 +120,38 @@ describe Projects::PipelinesController do expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico" end end + + describe 'POST retry.json' do + let!(:pipeline) { create(:ci_pipeline, :failed, project: project) } + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + before do + post :retry, namespace_id: project.namespace, + project_id: project, + id: pipeline.id, + format: :json + end + + it 'retries a pipeline without returning any content' do + expect(response).to have_http_status(:no_content) + expect(build.reload).to be_retried + end + end + + describe 'POST cancel.json' do + let!(:pipeline) { create(:ci_pipeline, project: project) } + let!(:build) { create(:ci_build, :running, pipeline: pipeline) } + + before do + post :cancel, namespace_id: project.namespace, + project_id: project, + id: pipeline.id, + format: :json + end + + it 'cancels a pipeline without returning any content' do + expect(response).to have_http_status(:no_content) + expect(pipeline.reload).to be_canceled + end + end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index eafc2154568..e46ef447df2 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -185,6 +185,7 @@ describe ProjectsController do expect(assigns(:project)).to eq(public_project) expect(response).to redirect_to("/#{public_project.full_path}") + expect(controller).not_to set_flash[:notice] end end end @@ -218,19 +219,33 @@ describe ProjectsController do expect(response).to redirect_to(namespace_project_path) end end + + context 'when requesting a redirected path' do + let!(:redirect_route) { public_project.redirect_routes.create!(path: "foo/bar") } + + it 'redirects to the canonical path' do + get :show, namespace_id: 'foo', id: 'bar' + + expect(response).to redirect_to(public_project) + expect(controller).to set_flash[:notice].to(/moved/) + end + end end describe "#update" do render_views let(:admin) { create(:admin) } + let(:project) { create(:project, :repository) } + let(:new_path) { 'renamed_path' } + let(:project_params) { { path: new_path } } + + before do + sign_in(admin) + end it "sets the repository to the right path after a rename" do - project = create(:project, :repository) - new_path = 'renamed_path' - project_params = { path: new_path } controller.instance_variable_set(:@project, project) - sign_in(admin) put :update, namespace_id: project.namespace, @@ -241,6 +256,34 @@ describe ProjectsController do expect(assigns(:repository).path).to eq(project.repository.path) expect(response).to have_http_status(302) end + + context 'when requesting the canonical path' do + it "is case-insensitive" do + controller.instance_variable_set(:@project, project) + + put :update, + namespace_id: 'FOo', + id: 'baR', + project: project_params + + expect(project.repository.path).to include(new_path) + expect(assigns(:repository).path).to eq(project.repository.path) + expect(response).to have_http_status(302) + end + end + + context 'when requesting a redirected path' do + let!(:redirect_route) { project.redirect_routes.create!(path: "foo/bar") } + + it 'returns not found' do + put :update, + namespace_id: 'foo', + id: 'bar', + project: project_params + + expect(response).to have_http_status(404) + end + end end describe "#destroy" do @@ -276,6 +319,31 @@ describe ProjectsController do expect(merge_request.reload.state).to eq('closed') end end + + context 'when requesting the canonical path' do + it "is case-insensitive" do + controller.instance_variable_set(:@project, project) + sign_in(admin) + + orig_id = project.id + delete :destroy, namespace_id: project.namespace, id: project.path.upcase + + expect { Project.find(orig_id) }.to raise_error(ActiveRecord::RecordNotFound) + expect(response).to have_http_status(302) + expect(response).to redirect_to(dashboard_projects_path) + end + end + + context 'when requesting a redirected path' do + let!(:redirect_route) { project.redirect_routes.create!(path: "foo/bar") } + + it 'returns not found' do + sign_in(admin) + delete :destroy, namespace_id: 'foo', id: 'bar' + + expect(response).to have_http_status(404) + end + end end describe 'PUT #new_issue_address' do @@ -397,6 +465,17 @@ describe ProjectsController do expect(parsed_body["Tags"]).to include("v1.0.0") expect(parsed_body["Commits"]).to include("123456") end + + context 'when requesting a redirected path' do + let!(:redirect_route) { public_project.redirect_routes.create!(path: "foo/bar") } + + it 'redirects to the canonical path' do + get :refs, namespace_id: 'foo', id: 'bar' + + expect(response).to redirect_to(refs_namespace_project_path(namespace_id: public_project.namespace, id: public_project)) + expect(controller).to set_flash[:notice].to(/moved/) + end + end end describe 'POST #preview_markdown' do diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index f67d26da0ac..7dedfe160a6 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -8,6 +8,93 @@ end describe UploadsController do let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } + describe 'POST create' do + let(:model) { 'personal_snippet' } + let(:snippet) { create(:personal_snippet, :public) } + let(:jpg) { fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg') } + let(:txt) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') } + + context 'when a user does not have permissions to upload a file' do + it "returns 401 when the user is not logged in" do + post :create, model: model, id: snippet.id, format: :json + + expect(response).to have_http_status(401) + end + + it "returns 404 when user can't comment on a snippet" do + private_snippet = create(:personal_snippet, :private) + + sign_in(user) + post :create, model: model, id: private_snippet.id, format: :json + + expect(response).to have_http_status(404) + end + end + + context 'when a user is logged in' do + before do + sign_in(user) + end + + it "returns an error without file" do + post :create, model: model, id: snippet.id, format: :json + + expect(response).to have_http_status(422) + end + + it "returns an error with invalid model" do + expect { post :create, model: 'invalid', id: snippet.id, format: :json } + .to raise_error(ActionController::UrlGenerationError) + end + + it "returns 404 status when object not found" do + post :create, model: model, id: 9999, format: :json + + expect(response).to have_http_status(404) + end + + context 'with valid image' do + before do + post :create, model: 'personal_snippet', id: snippet.id, file: jpg, format: :json + end + + it 'returns a content with original filename, new link, and correct type.' do + expect(response.body).to match '\"alt\":\"rails_sample\"' + expect(response.body).to match "\"url\":\"/uploads" + end + + it 'creates a corresponding Upload record' do + upload = Upload.last + + aggregate_failures do + expect(upload).to exist + expect(upload.model).to eq snippet + end + end + end + + context 'with valid non-image file' do + before do + post :create, model: 'personal_snippet', id: snippet.id, file: txt, format: :json + end + + it 'returns a content with original filename, new link, and correct type.' do + expect(response.body).to match '\"alt\":\"doc_sample.txt\"' + expect(response.body).to match "\"url\":\"/uploads" + end + + it 'creates a corresponding Upload record' do + upload = Upload.last + + aggregate_failures do + expect(upload).to exist + expect(upload.model).to eq snippet + end + end + end + end + end + describe "GET show" do context 'Content-Disposition security measures' do let(:project) { create(:empty_project, :public) } diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index bbe9aaf737f..74c5aa44ba9 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -4,15 +4,6 @@ describe UsersController do let(:user) { create(:user) } describe 'GET #show' do - it 'is case-insensitive' do - user = create(:user, username: 'CamelCaseUser') - sign_in(user) - - get :show, username: user.username.downcase - - expect(response).to be_success - end - context 'with rendered views' do render_views @@ -45,9 +36,9 @@ describe UsersController do end context 'when logged out' do - it 'renders 404' do + it 'redirects to login page' do get :show, username: user.username - expect(response).to have_http_status(404) + expect(response).to redirect_to new_user_session_path end end @@ -61,6 +52,58 @@ describe UsersController do end end end + + context 'when requesting the canonical path' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + before { sign_in(user) } + + context 'with exactly matching casing' do + it 'responds with success' do + get :show, username: user.username + + expect(response).to be_success + end + end + + context 'with different casing' do + it 'redirects to the correct casing' do + get :show, username: user.username.downcase + + expect(response).to redirect_to(user) + expect(controller).not_to set_flash[:notice] + end + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'redirects to the canonical path' do + get :show, username: redirect_route.path + + expect(response).to redirect_to(user) + expect(controller).to set_flash[:notice].to(/moved/) + end + end + + context 'when a user by that username does not exist' do + context 'when logged out' do + it 'redirects to login page' do + get :show, username: 'nonexistent' + expect(response).to redirect_to new_user_session_path + end + end + + context 'when logged in' do + before { sign_in(user) } + + it 'renders 404' do + get :show, username: 'nonexistent' + expect(response).to have_http_status(404) + end + end + end end describe 'GET #calendar' do @@ -88,11 +131,45 @@ describe UsersController do expect(assigns(:contributions_calendar).projects.count).to eq(2) end end + + context 'when requesting the canonical path' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + before { sign_in(user) } + + context 'with exactly matching casing' do + it 'responds with success' do + get :calendar, username: user.username + + expect(response).to be_success + end + end + + context 'with different casing' do + it 'redirects to the correct casing' do + get :calendar, username: user.username.downcase + + expect(response).to redirect_to(user_calendar_path(user)) + expect(controller).not_to set_flash[:notice] + end + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'redirects to the canonical path' do + get :calendar, username: redirect_route.path + + expect(response).to redirect_to(user_calendar_path(user)) + expect(controller).to set_flash[:notice].to(/moved/) + end + end end describe 'GET #calendar_activities' do let!(:project) { create(:empty_project) } - let!(:user) { create(:user) } + let(:user) { create(:user) } before do allow_any_instance_of(User).to receive(:contributed_projects_ids).and_return([project.id]) @@ -110,6 +187,38 @@ describe UsersController do get :calendar_activities, username: user.username expect(response).to render_template('calendar_activities') end + + context 'when requesting the canonical path' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + context 'with exactly matching casing' do + it 'responds with success' do + get :calendar_activities, username: user.username + + expect(response).to be_success + end + end + + context 'with different casing' do + it 'redirects to the correct casing' do + get :calendar_activities, username: user.username.downcase + + expect(response).to redirect_to(user_calendar_activities_path(user)) + expect(controller).not_to set_flash[:notice] + end + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'redirects to the canonical path' do + get :calendar_activities, username: redirect_route.path + + expect(response).to redirect_to(user_calendar_activities_path(user)) + expect(controller).to set_flash[:notice].to(/moved/) + end + end end describe 'GET #snippets' do @@ -132,5 +241,83 @@ describe UsersController do expect(JSON.parse(response.body)).to have_key('html') end end + + context 'when requesting the canonical path' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + context 'with exactly matching casing' do + it 'responds with success' do + get :snippets, username: user.username + + expect(response).to be_success + end + end + + context 'with different casing' do + it 'redirects to the correct casing' do + get :snippets, username: user.username.downcase + + expect(response).to redirect_to(user_snippets_path(user)) + expect(controller).not_to set_flash[:notice] + end + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'redirects to the canonical path' do + get :snippets, username: redirect_route.path + + expect(response).to redirect_to(user_snippets_path(user)) + expect(controller).to set_flash[:notice].to(/moved/) + end + end + end + + describe 'GET #exists' do + before do + sign_in(user) + end + + context 'when user exists' do + it 'returns JSON indicating the user exists' do + get :exists, username: user.username + + expected_json = { exists: true }.to_json + expect(response.body).to eq(expected_json) + end + + context 'when the casing is different' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + it 'returns JSON indicating the user exists' do + get :exists, username: user.username.downcase + + expected_json = { exists: true }.to_json + expect(response.body).to eq(expected_json) + end + end + end + + context 'when the user does not exist' do + it 'returns JSON indicating the user does not exist' do + get :exists, username: 'foo' + + expected_json = { exists: false }.to_json + expect(response.body).to eq(expected_json) + end + + context 'when a user changed their username' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'returns JSON indicating a user by that username does not exist' do + get :exists, username: 'old-username' + + expected_json = { exists: false }.to_json + expect(response.body).to eq(expected_json) + end + end + end end end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 86f51ffca99..52f76b094a3 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -17,6 +17,10 @@ FactoryGirl.define do visibility_level Gitlab::VisibilityLevel::PRIVATE end + trait :with_avatar do + avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) } + end + trait :access_requestable do request_access_enabled true end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 44c3186d813..046974dcd6e 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -29,6 +29,8 @@ FactoryGirl.define do factory :discussion_note_on_commit, traits: [:on_commit], class: DiscussionNote + factory :discussion_note_on_personal_snippet, traits: [:on_personal_snippet], class: DiscussionNote + factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote factory :legacy_diff_note_on_merge_request, traits: [:on_merge_request, :legacy_diff_note], class: LegacyDiffNote do diff --git a/spec/factories/users.rb b/spec/factories/users.rb index e1ae94a08e4..33fa80772ff 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -29,6 +29,10 @@ FactoryGirl.define do after(:build) { |user, _| user.block! } end + trait :with_avatar do + avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) } + end + trait :two_factor_via_otp do before(:create) do |user| user.otp_required_for_login = true diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 58b14e09740..9ea325ab41b 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -32,7 +32,7 @@ describe "Dashboard Issues Feed", feature: true do end context "issue with basic fields" do - let!(:issue2) { create(:issue, author: user, assignee: assignee, project: project2, description: 'test desc') } + let!(:issue2) { create(:issue, author: user, assignees: [assignee], project: project2, description: 'test desc') } it "renders issue fields" do visit issues_dashboard_path(:atom, private_token: user.private_token) @@ -41,7 +41,7 @@ describe "Dashboard Issues Feed", feature: true do expect(entry).to be_present expect(entry).to have_selector('author email', text: issue2.author_public_email) - expect(entry).to have_selector('assignee email', text: issue2.assignee_public_email) + expect(entry).to have_selector('assignees email', text: assignee.public_email) expect(entry).not_to have_selector('labels') expect(entry).not_to have_selector('milestone') expect(entry).to have_selector('description', text: issue2.description) @@ -51,7 +51,7 @@ describe "Dashboard Issues Feed", feature: true do context "issue with label and milestone" do let!(:milestone1) { create(:milestone, project: project1, title: 'v1') } let!(:label1) { create(:label, project: project1, title: 'label1') } - let!(:issue1) { create(:issue, author: user, assignee: assignee, project: project1, milestone: milestone1) } + let!(:issue1) { create(:issue, author: user, assignees: [assignee], project: project1, milestone: milestone1) } before do issue1.labels << label1 @@ -64,7 +64,7 @@ describe "Dashboard Issues Feed", feature: true do expect(entry).to be_present expect(entry).to have_selector('author email', text: issue1.author_public_email) - expect(entry).to have_selector('assignee email', text: issue1.assignee_public_email) + expect(entry).to have_selector('assignees email', text: assignee.public_email) expect(entry).to have_selector('labels label', text: label1.title) expect(entry).to have_selector('milestone', text: milestone1.title) expect(entry).not_to have_selector('description') diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index b3903ec2faf..4f6754ad541 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -6,7 +6,7 @@ describe 'Issues Feed', feature: true do let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } let!(:group) { create(:group) } let!(:project) { create(:project) } - let!(:issue) { create(:issue, author: user, assignee: assignee, project: project) } + let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) } before do project.team << [user, :developer] @@ -22,7 +22,8 @@ describe 'Issues Feed', feature: true do to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignee email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) expect(body).to have_selector('entry summary', text: issue.title) end end @@ -36,7 +37,8 @@ describe 'Issues Feed', feature: true do to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignee email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) expect(body).to have_selector('entry summary', text: issue.title) end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index a172ce1e8c0..18585488e26 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -71,7 +71,7 @@ describe 'Issue Boards', feature: true, js: true do let!(:list2) { create(:list, board: board, label: development, position: 1) } let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning], relative_position: 8) } + let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], labels: [planning], relative_position: 8) } let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) } let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) } let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) } diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index 4a4c13e79c8..e1367c675e5 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -98,7 +98,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do end context 'assignee' do - let!(:issue) { create(:issue, project: project, assignee: user2) } + let!(:issue) { create(:issue, project: project, assignees: [user2]) } before do project.team << [user2, :developer] diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index bafa4f05937..7c53d2b47d9 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -4,13 +4,14 @@ describe 'Issue Boards', feature: true, js: true do include WaitForVueResource let(:user) { create(:user) } + let(:user2) { create(:user) } let(:project) { create(:empty_project, :public) } let!(:milestone) { create(:milestone, project: project) } let!(:development) { create(:label, project: project, name: 'Development') } let!(:bug) { create(:label, project: project, name: 'Bug') } let!(:regression) { create(:label, project: project, name: 'Regression') } let!(:stretch) { create(:label, project: project, name: 'Stretch') } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development], relative_position: 2) } + let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], milestone: milestone, labels: [development], relative_position: 2) } let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) } let(:board) { create(:board, project: project) } let!(:list) { create(:list, board: board, label: development, position: 0) } @@ -112,10 +113,11 @@ describe 'Issue Boards', feature: true, js: true do page.within('.dropdown-menu-user') do click_link 'Unassigned' - - wait_for_vue_resource end + find('.dropdown-menu-toggle').click + wait_for_vue_resource + expect(page).to have_content('No assignee') end @@ -128,7 +130,7 @@ describe 'Issue Boards', feature: true, js: true do page.within(find('.assignee')) do expect(page).to have_content('No assignee') - click_link 'assign yourself' + click_button 'assign yourself' wait_for_vue_resource @@ -138,7 +140,7 @@ describe 'Issue Boards', feature: true, js: true do expect(card).to have_selector('.avatar') end - it 'resets assignee dropdown' do + it 'updates assignee dropdown' do click_card(card) page.within('.assignee') do @@ -162,7 +164,7 @@ describe 'Issue Boards', feature: true, js: true do page.within('.assignee') do click_link 'Edit' - expect(page).not_to have_selector('.is-active') + expect(page).to have_selector('.is-active') end end end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index b93275c330b..7c9d522273b 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -62,6 +62,25 @@ feature 'Cycle Analytics', feature: true, js: true do expect_issue_to_be_present end end + + context "when my preferred language is Spanish" do + before do + user.update_attribute(:preferred_language, 'es') + + project.team << [user, :master] + login_as(user) + visit namespace_project_cycle_analytics_path(project.namespace, project) + wait_for_ajax + end + + it 'shows the content in Spanish' do + expect(page).to have_content('Estado del Pipeline') + end + + it 'resets the language to English' do + expect(I18n.locale).to eq(:en) + end + end end context "as a guest" do diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index 4fca7577e74..6f7bf0eba6e 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -7,7 +7,7 @@ describe 'Navigation bar counter', feature: true, caching: true do let(:merge_request) { create(:merge_request, source_project: project) } before do - issue.update(assignee: user) + issue.assignees = [user] merge_request.update(assignee: user) login_as(user) end @@ -17,7 +17,9 @@ describe 'Navigation bar counter', feature: true, caching: true do expect_counters('issues', '1') - issue.update(assignee: nil) + issue.assignees = [] + + user.update_cache_counts Timecop.travel(3.minutes.from_now) do visit issues_path diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index f4420814c3a..86c7954e60c 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -11,7 +11,7 @@ RSpec.describe 'Dashboard Issues', feature: true do let!(:authored_issue) { create :issue, author: current_user, project: project } let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project } - let!(:assigned_issue) { create :issue, assignee: current_user, project: project } + let!(:assigned_issue) { create :issue, assignees: [current_user], project: project } let!(:other_issue) { create :issue, project: project } before do @@ -30,6 +30,11 @@ RSpec.describe 'Dashboard Issues', feature: true do find('#assignee_id', visible: false).set('') find('.js-author-search', match: :first).click find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click + find('.js-author-search', match: :first).click + + page.within '.dropdown-menu-user' do + expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible + end expect(page).to have_content(authored_issue.title) expect(page).to have_content(authored_issue_on_public_project.title) diff --git a/spec/features/dashboard_issues_spec.rb b/spec/features/dashboard_issues_spec.rb index b6b87905231..ad60fb2c74f 100644 --- a/spec/features/dashboard_issues_spec.rb +++ b/spec/features/dashboard_issues_spec.rb @@ -10,8 +10,8 @@ describe "Dashboard Issues filtering", feature: true, js: true do project.team << [user, :master] login_as(user) - create(:issue, project: project, author: user, assignee: user) - create(:issue, project: project, author: user, assignee: user, milestone: milestone) + create(:issue, project: project, author: user, assignees: [user]) + create(:issue, project: project, author: user, assignees: [user], milestone: milestone) visit_issues end diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb index 01b1aee4fd3..005a029a393 100644 --- a/spec/features/gitlab_flavored_markdown_spec.rb +++ b/spec/features/gitlab_flavored_markdown_spec.rb @@ -54,14 +54,16 @@ describe "GitLab Flavored Markdown", feature: true do before do @other_issue = create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) @issue = create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project, title: "fix #{@other_issue.to_reference}", description: "ask #{fred.to_reference} for details") + + @note = create(:note_on_issue, noteable: @issue, project: @issue.project, note: "Hello world") end it "renders subject in issues#index" do @@ -81,14 +83,6 @@ describe "GitLab Flavored Markdown", feature: true do expect(page).to have_link(fred.to_reference) end - - it "renders updated subject once edited somewhere else in issues#show" do - visit namespace_project_issue_path(project.namespace, project, @issue) - @issue.update(title: "fix #{@other_issue.to_reference} and update") - - wait_for_vue_resource - expect(page).to have_text("fix #{@other_issue.to_reference} and update") - end end describe "for merge requests" do diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb new file mode 100644 index 00000000000..cc25db4ad60 --- /dev/null +++ b/spec/features/groups/group_settings_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +feature 'Edit group settings', feature: true do + given(:user) { create(:user) } + given(:group) { create(:group, path: 'foo') } + + background do + group.add_owner(user) + login_as(user) + end + + describe 'when the group path is changed' do + let(:new_group_path) { 'bar' } + let(:old_group_full_path) { "/#{group.path}" } + let(:new_group_full_path) { "/#{new_group_path}" } + + scenario 'the group is accessible via the new path' do + update_path(new_group_path) + visit new_group_full_path + expect(current_path).to eq(new_group_full_path) + expect(find('h1.group-title')).to have_content(new_group_path) + end + + scenario 'the old group path redirects to the new path' do + update_path(new_group_path) + visit old_group_full_path + expect(current_path).to eq(new_group_full_path) + expect(find('h1.group-title')).to have_content(new_group_path) + end + + context 'with a subgroup' do + given!(:subgroup) { create(:group, parent: group, path: 'subgroup') } + given(:old_subgroup_full_path) { "/#{group.path}/#{subgroup.path}" } + given(:new_subgroup_full_path) { "/#{new_group_path}/#{subgroup.path}" } + + scenario 'the subgroup is accessible via the new path' do + update_path(new_group_path) + visit new_subgroup_full_path + expect(current_path).to eq(new_subgroup_full_path) + expect(find('h1.group-title')).to have_content(subgroup.path) + end + + scenario 'the old subgroup path redirects to the new path' do + update_path(new_group_path) + visit old_subgroup_full_path + expect(current_path).to eq(new_subgroup_full_path) + expect(find('h1.group-title')).to have_content(subgroup.path) + end + end + + context 'with a project' do + given!(:project) { create(:project, group: group, path: 'project') } + given(:old_project_full_path) { "/#{group.path}/#{project.path}" } + given(:new_project_full_path) { "/#{new_group_path}/#{project.path}" } + + before(:context) { TestEnv.clean_test_path } + after(:example) { TestEnv.clean_test_path } + + scenario 'the project is accessible via the new path' do + update_path(new_group_path) + visit new_project_full_path + expect(current_path).to eq(new_project_full_path) + expect(find('h1.project-title')).to have_content(project.name) + end + + scenario 'the old project path redirects to the new path' do + update_path(new_group_path) + visit old_project_full_path + expect(current_path).to eq(new_project_full_path) + expect(find('h1.project-title')).to have_content(project.name) + end + end + end +end + +def update_path(new_group_path) + visit edit_group_path(group) + fill_in 'group_path', with: new_group_path + click_button 'Save group' +end diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 71df3c949db..853632614c4 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -7,7 +7,7 @@ describe 'Awards Emoji', feature: true do let!(:user) { create(:user) } let(:issue) do create(:issue, - assignee: @user, + assignees: [user], project: project) end diff --git a/spec/features/issues/award_spec.rb b/spec/features/issues/award_spec.rb index 401e1ea2b89..08e3f99e29f 100644 --- a/spec/features/issues/award_spec.rb +++ b/spec/features/issues/award_spec.rb @@ -6,9 +6,12 @@ feature 'Issue awards', js: true, feature: true do let(:issue) { create(:issue, project: project) } describe 'logged in' do + include WaitForVueResource + before do login_as(user) visit namespace_project_issue_path(project.namespace, project, issue) + wait_for_vue_resource end it 'adds award to issue' do @@ -38,8 +41,11 @@ feature 'Issue awards', js: true, feature: true do end describe 'logged out' do + include WaitForVueResource + before do visit namespace_project_issue_path(project.namespace, project, issue) + wait_for_vue_resource end it 'does not see award menu button' do diff --git a/spec/features/issues/create_branch_merge_request_spec.rb b/spec/features/issues/create_branch_merge_request_spec.rb new file mode 100644 index 00000000000..44c19275ae5 --- /dev/null +++ b/spec/features/issues/create_branch_merge_request_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +feature 'Create Branch/Merge Request Dropdown on issue page', feature: true, js: true do + let(:user) { create(:user) } + let!(:project) { create(:project) } + let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') } + + context 'for team members' do + before do + project.team << [user, :developer] + login_as(user) + end + + it 'allows creating a merge request from the issue page' do + visit namespace_project_issue_path(project.namespace, project, issue) + + select_dropdown_option('create-mr') + + wait_for_ajax + + expect(page).to have_content("created branch 1-cherry-coloured-funk") + expect(page).to have_content("mentioned in merge request !1") + + visit namespace_project_merge_request_path(project.namespace, project, MergeRequest.first) + + expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"') + expect(current_path).to eq(namespace_project_merge_request_path(project.namespace, project, MergeRequest.first)) + end + + it 'allows creating a branch from the issue page' do + visit namespace_project_issue_path(project.namespace, project, issue) + + select_dropdown_option('create-branch') + + wait_for_ajax + + expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk') + expect(current_path).to eq namespace_project_tree_path(project.namespace, project, '1-cherry-coloured-funk') + end + + context "when there is a referenced merge request" do + let!(:note) do + create(:note, :on_issue, :system, project: project, noteable: issue, + note: "mentioned in #{referenced_mr.to_reference}") + end + + let(:referenced_mr) do + create(:merge_request, :simple, source_project: project, target_project: project, + description: "Fixes #{issue.to_reference}", author: user) + end + + before do + referenced_mr.cache_merge_request_closes_issues!(user) + + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'disables the create branch button' do + expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hide)') + expect(page).to have_css('.create-mr-dropdown-wrap .available.hide', visible: false) + expect(page).to have_content /1 Related Merge Request/ + end + end + + context 'when issue is confidential' do + it 'disables the create branch button' do + issue = create(:issue, :confidential, project: project) + + visit namespace_project_issue_path(project.namespace, project, issue) + + expect(page).not_to have_css('.create-mr-dropdown-wrap') + end + end + end + + context 'for visitors' do + before do + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'shows no buttons' do + expect(page).not_to have_selector('.create-mr-dropdown-wrap') + end + end + + def select_dropdown_option(option) + find('.create-mr-dropdown-wrap .dropdown-toggle').click + find("li[data-value='#{option}']").click + find('.js-create-merge-request').click + end +end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index c824aa6a414..a8f4e2d7e10 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -51,15 +51,15 @@ describe 'Filter issues', js: true, feature: true do create(:issue, project: project, title: "issue with 'single quotes'") create(:issue, project: project, title: "issue with \"double quotes\"") create(:issue, project: project, title: "issue with !@\#{$%^&*()-+") - create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignee: user) - create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignee: user) + create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user]) + create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user]) issue = create(:issue, title: "Bug 2", project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue.labels << bug_label issue_with_caps_label = create(:issue, @@ -67,7 +67,7 @@ describe 'Filter issues', js: true, feature: true do project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue_with_caps_label.labels << caps_sensitive_label issue_with_everything = create(:issue, @@ -75,7 +75,7 @@ describe 'Filter issues', js: true, feature: true do project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue_with_everything.labels << bug_label issue_with_everything.labels << caps_sensitive_label diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 755992069ff..87adce3cddd 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' describe 'New/edit issue', feature: true, js: true do include GitlabRoutingHelper + include ActionView::Helpers::JavaScriptHelper let!(:project) { create(:project) } let!(:user) { create(:user)} @@ -9,7 +10,7 @@ describe 'New/edit issue', feature: true, js: true do let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } let!(:label2) { create(:label, project: project) } - let!(:issue) { create(:issue, project: project, assignee: user, milestone: milestone) } + let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) } before do project.team << [user, :master] @@ -22,23 +23,62 @@ describe 'New/edit issue', feature: true, js: true do visit new_namespace_project_issue_path(project.namespace, project) end + describe 'multiple assignees' do + before do + click_button 'Unassigned' + end + + it 'unselects other assignees when unassigned is selected' do + page.within '.dropdown-menu-user' do + click_link user2.name + end + + page.within '.dropdown-menu-user' do + click_link 'Unassigned' + end + + page.within '.js-assignee-search' do + expect(page).to have_content 'Unassigned' + end + + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match('0') + end + + it 'toggles assign to me when current user is selected and unselected' do + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible + + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('a', text: 'Assign to me')).to be_visible + end + end + it 'allows user to create new issue' do fill_in 'issue_title', with: 'title' fill_in 'issue_description', with: 'title' expect(find('a', text: 'Assign to me')).to be_visible - click_button 'Assignee' + click_button 'Unassigned' page.within '.dropdown-menu-user' do click_link user2.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s) page.within '.js-assignee-search' do expect(page).to have_content user2.name end expect(find('a', text: 'Assign to me')).to be_visible click_link 'Assign to me' - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false) + + expect(assignee_ids[0].value).to match(user.id.to_s) + page.within '.js-assignee-search' do expect(page).to have_content user.name end @@ -68,7 +108,7 @@ describe 'New/edit issue', feature: true, js: true do page.within '.issuable-sidebar' do page.within '.assignee' do - expect(page).to have_content user.name + expect(page).to have_content "Assignee" end page.within '.milestone' do @@ -105,6 +145,33 @@ describe 'New/edit issue', feature: true, js: true do expect(find('.js-label-select')).to have_content('Labels') end + + it 'correctly updates the selected user when changing assignee' do + click_button 'Unassigned' + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) + + click_button user.name + + expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user.id.to_s) + + # check the ::before pseudo element to ensure checkmark icon is present + expect(before_for_selector('.dropdown-menu-selectable a.is-active')).not_to eq('') + expect(before_for_selector('.dropdown-menu-selectable a:not(.is-active)')).to eq('') + + page.within '.dropdown-menu-user' do + click_link user2.name + end + + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s) + + click_button user2.name + + expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user2.id.to_s) + end end context 'edit issue' do @@ -113,7 +180,7 @@ describe 'New/edit issue', feature: true, js: true do end it 'allows user to update issue' do - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible @@ -154,4 +221,14 @@ describe 'New/edit issue', feature: true, js: true do end end end + + def before_for_selector(selector) + js = <<-JS.strip_heredoc + (function(selector) { + var el = document.querySelector(selector); + return window.getComputedStyle(el, '::before').getPropertyValue('content'); + })("#{escape_javascript(selector)}") + JS + page.evaluate_script(js) + end end diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index baacd7edb86..0de0f93089a 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -3,7 +3,8 @@ require 'rails_helper' feature 'Issue Sidebar', feature: true do include MobileHelpers - let(:project) { create(:project, :public) } + let(:group) { create(:group, :nested) } + let(:project) { create(:project, :public, namespace: group) } let(:issue) { create(:issue, project: project) } let!(:user) { create(:user)} let!(:label) { create(:label, project: project, title: 'bug') } @@ -41,6 +42,21 @@ feature 'Issue Sidebar', feature: true do expect(page).to have_content(user2.name) end end + + it 'assigns yourself' do + find('.block.assignee .dropdown-menu-toggle').click + + click_button 'assign yourself' + + wait_for_ajax + + find('.block.assignee .edit-link').click + + page.within '.dropdown-menu-user' do + expect(page.find('.dropdown-header')).to be_visible + expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name) + end + end end context 'as a allowed user' do @@ -55,10 +71,12 @@ feature 'Issue Sidebar', feature: true do # Resize the window resize_screen_sm # Make sure the sidebar is collapsed + find(sidebar_selector) expect(page).to have_css(sidebar_selector) # Once is collapsed let's open the sidebard and reload open_issue_sidebar refresh + find(sidebar_selector) expect(page).to have_css(sidebar_selector) # Restore the window size as it was including the sidebar restore_window_size @@ -149,9 +167,7 @@ feature 'Issue Sidebar', feature: true do end def open_issue_sidebar - page.within('aside.right-sidebar.right-sidebar-collapsed') do - find('.js-sidebar-toggle').click - sleep 1 - end + find('aside.right-sidebar.right-sidebar-collapsed .js-sidebar-toggle').trigger('click') + find('aside.right-sidebar.right-sidebar-expanded') end end diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb deleted file mode 100644 index c0ab42c6822..00000000000 --- a/spec/features/issues/new_branch_button_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -require 'rails_helper' - -feature 'Start new branch from an issue', feature: true, js: true do - let!(:project) { create(:project) } - let!(:issue) { create(:issue, project: project) } - let!(:user) { create(:user)} - - context "for team members" do - before do - project.team << [user, :master] - login_as(user) - end - - it 'shows the new branch button' do - visit namespace_project_issue_path(project.namespace, project, issue) - - expect(page).to have_css('#new-branch .available') - end - - context "when there is a referenced merge request" do - let!(:note) do - create(:note, :on_issue, :system, project: project, noteable: issue, - note: "mentioned in #{referenced_mr.to_reference}") - end - - let(:referenced_mr) do - create(:merge_request, :simple, source_project: project, target_project: project, - description: "Fixes #{issue.to_reference}", author: user) - end - - before do - referenced_mr.cache_merge_request_closes_issues!(user) - - visit namespace_project_issue_path(project.namespace, project, issue) - end - - it "hides the new branch button" do - expect(page).to have_css('#new-branch .unavailable') - expect(page).not_to have_css('#new-branch .available') - expect(page).to have_content /1 Related Merge Request/ - end - end - - context 'when issue is confidential' do - it 'hides the new branch button' do - issue = create(:issue, :confidential, project: project) - - visit namespace_project_issue_path(project.namespace, project, issue) - - expect(page).not_to have_css('#new-branch') - end - end - end - - context 'for visitors' do - it 'shows no buttons' do - visit namespace_project_issue_path(project.namespace, project, issue) - - expect(page).not_to have_css('#new-branch') - end - end -end diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb index 378f6de1a78..58b3215f14c 100644 --- a/spec/features/issues/note_polling_spec.rb +++ b/spec/features/issues/note_polling_spec.rb @@ -4,14 +4,77 @@ feature 'Issue notes polling', :feature, :js do let(:project) { create(:empty_project, :public) } let(:issue) { create(:issue, project: project) } - before do - visit namespace_project_issue_path(project.namespace, project, issue) + describe 'creates' do + before do + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'displays the new comment' do + note = create(:note, noteable: issue, project: project, note: 'Looks good!') + page.execute_script('notes.refresh();') + + expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') + end end - it 'should display the new comment' do - note = create(:note, noteable: issue, project: project, note: 'Looks good!') - page.execute_script('notes.refresh();') + describe 'updates' do + let(:user) { create(:user) } + let(:note_text) { "Hello World" } + let(:updated_text) { "Bye World" } + let!(:existing_note) { create(:note, noteable: issue, project: project, author: user, note: note_text) } + + before do + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'displays the updated content' do + expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) + + update_note(existing_note, updated_text) + + expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) + end + + it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do + find("#note_#{existing_note.id} .js-note-edit").click + + expect(page).to have_field("note[note]", with: note_text) + + update_note(existing_note, updated_text) + + expect(page).to have_field("note[note]", with: updated_text) + end + + it 'when editing but you changed some things, and an update comes in, show a warning' do + find("#note_#{existing_note.id} .js-note-edit").click - expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') + expect(page).to have_field("note[note]", with: note_text) + + find("#note_#{existing_note.id} .js-note-text").set('something random') + + update_note(existing_note, updated_text) + + expect(page).to have_selector(".alert") + end + + it 'when editing but you changed some things, an update comes in, and you press cancel, show the updated content' do + find("#note_#{existing_note.id} .js-note-edit").click + + expect(page).to have_field("note[note]", with: note_text) + + find("#note_#{existing_note.id} .js-note-text").set('something random') + + update_note(existing_note, updated_text) + + find("#note_#{existing_note.id} .note-edit-cancel").click + + expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) + end + end + + def update_note(note, new_text) + note.update(note: new_text) + page.execute_script('notes.refresh();') end end diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index 7fa83c1fcf7..b250fa2ed3c 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -99,7 +99,7 @@ feature 'Multiple issue updating from issues#index', feature: true do end def create_assigned - create(:issue, project: project, assignee: user) + create(:issue, project: project, assignees: [user]) end def create_with_milestone diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 81cc8513454..5285dda361b 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -18,7 +18,7 @@ describe 'Issues', feature: true do let!(:issue) do create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) end @@ -43,7 +43,7 @@ describe 'Issues', feature: true do let!(:issue) do create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) end @@ -61,7 +61,7 @@ describe 'Issues', feature: true do expect(page).to have_content 'No assignee - assign yourself' end - expect(issue.reload.assignee).to be_nil + expect(issue.reload.assignees).to be_empty end end @@ -138,7 +138,7 @@ describe 'Issues', feature: true do describe 'Issue info' do it 'excludes award_emoji from comment count' do - issue = create(:issue, author: @user, assignee: @user, project: project, title: 'foobar') + issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'foobar') create(:award_emoji, awardable: issue) visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id) @@ -153,14 +153,14 @@ describe 'Issues', feature: true do %w(foobar barbaz gitlab).each do |title| create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project, title: title) end @issue = Issue.find_by(title: 'foobar') @issue.milestone = create(:milestone, project: project) - @issue.assignee = nil + @issue.assignees = [] @issue.save end @@ -351,9 +351,9 @@ describe 'Issues', feature: true do let(:user2) { create(:user) } before do - foo.assignee = user2 + foo.assignees << user2 foo.save - bar.assignee = user2 + bar.assignees << user2 bar.save end @@ -396,7 +396,7 @@ describe 'Issues', feature: true do end describe 'update labels from issue#show', js: true do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } let!(:label) { create(:label, project: project) } before do @@ -415,7 +415,7 @@ describe 'Issues', feature: true do end describe 'update assignee from issue#show' do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } context 'by authorized user' do it 'allows user to select unassigned', js: true do @@ -426,10 +426,14 @@ describe 'Issues', feature: true do click_link 'Edit' click_link 'Unassigned' + first('.title').click expect(page).to have_content 'No assignee' end - expect(issue.reload.assignee).to be_nil + # wait_for_ajax does not work with vue-resource at the moment + sleep 1 + + expect(issue.reload.assignees).to be_empty end it 'allows user to select an assignee', js: true do @@ -461,14 +465,18 @@ describe 'Issues', feature: true do click_link 'Edit' click_link @user.name - page.within '.value' do + find('.dropdown-menu-toggle').click + + page.within '.value .author' do expect(page).to have_content @user.name end click_link 'Edit' click_link @user.name - page.within '.value' do + find('.dropdown-menu-toggle').click + + page.within '.value .assign-yourself' do expect(page).to have_content "No assignee" end end @@ -487,7 +495,7 @@ describe 'Issues', feature: true do login_with guest visit namespace_project_issue_path(project.namespace, project, issue) - expect(page).to have_content issue.assignee.name + expect(page).to have_content issue.assignees.first.name end end end @@ -558,7 +566,7 @@ describe 'Issues', feature: true do let(:user2) { create(:user) } before do - issue.assignee = user2 + issue.assignees << user2 issue.save end end @@ -655,7 +663,7 @@ describe 'Issues', feature: true do describe 'due date' do context 'update due on issue#show', js: true do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } before do visit namespace_project_issue_path(project.namespace, project, issue) @@ -702,7 +710,7 @@ describe 'Issues', feature: true do include WaitForVueResource it 'updates the title', js: true do - issue = create(:issue, author: @user, assignee: @user, project: project, title: 'new title') + issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'new title') visit namespace_project_issue_path(project.namespace, project, issue) diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb index 43cc6f2a2a7..ec49003772b 100644 --- a/spec/features/merge_requests/assign_issues_spec.rb +++ b/spec/features/merge_requests/assign_issues_spec.rb @@ -33,7 +33,7 @@ feature 'Merge request issue assignment', js: true, feature: true do end it "doesn't display if related issues are already assigned" do - [issue1, issue2].each { |issue| issue.update!(assignee: user) } + [issue1, issue2].each { |issue| issue.update!(assignees: [user]) } visit_merge_request diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index f0fec625108..f1b3e7f158c 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -20,7 +20,6 @@ feature 'Create New Merge Request', feature: true, js: true do expect(page).to have_content('Target branch') first('.js-source-branch').click - first('.dropdown-source-branch .dropdown-content') find('.dropdown-source-branch .dropdown-content a', match: :first).click expect(page).to have_content "b83d6e3" @@ -35,8 +34,7 @@ feature 'Create New Merge Request', feature: true, js: true do expect(page).to have_content('Target branch') first('.js-target-branch').click - first('.dropdown-target-branch .dropdown-content') - first('.dropdown-target-branch .dropdown-content a', text: 'v1.1.0').click + find('.dropdown-target-branch .dropdown-content a', text: 'v1.1.0', match: :first).click expect(page).to have_content "b83d6e3" end diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb index c7cc4d6bc72..7fc0e2ce6ec 100644 --- a/spec/features/merge_requests/user_posts_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_notes_spec.rb @@ -98,6 +98,7 @@ describe 'Merge requests > User posts notes', :js do find('.btn-save').click end + wait_for_ajax find('.note').hover find('.js-note-edit').click diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb index 1c0f21e5616..f0ad57eb92f 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -160,6 +160,7 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do it 'changes target branch from a note' do write_note("message start \n/target_branch merge-test\n message end.") + wait_for_ajax expect(page).not_to have_content('/target_branch') expect(page).to have_content('message start') expect(page).to have_content('message end.') diff --git a/spec/features/merge_requests/versions_spec.rb b/spec/features/merge_requests/versions_spec.rb index 7a2da623c58..2b5b803946c 100644 --- a/spec/features/merge_requests/versions_spec.rb +++ b/spec/features/merge_requests/versions_spec.rb @@ -24,7 +24,12 @@ feature 'Merge Request versions', js: true, feature: true do before do page.within '.mr-version-dropdown' do find('.btn-default').click - find(:link, 'version 1').trigger('click') + click_link 'version 1' + end + + # Wait for the page to load + page.within '.mr-version-dropdown' do + expect(page).to have_content 'version 1' end end @@ -36,8 +41,8 @@ feature 'Merge Request versions', js: true, feature: true do expect(page).to have_content '5 changed files' end - it 'show the message about disabled comment creation' do - expect(page).to have_content 'comment creation is disabled' + it 'show the message about comments' do + expect(page).to have_content 'Not all comments are displayed' end it 'shows comments that were last relevant at that version' do @@ -52,15 +57,41 @@ feature 'Merge Request versions', js: true, feature: true do outdated_diff_note.position = outdated_diff_note.original_position outdated_diff_note.save! + visit current_url + expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']") end + + it 'allows commenting' do + diff_file_selector = ".diff-file[id='7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44']" + line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_2_2' + + page.within(diff_file_selector) do + find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").trigger 'mouseover' + find(".line_holder[id='#{line_code}'] button").trigger 'click' + + page.within("form[data-line-code='#{line_code}']") do + fill_in "note[note]", with: "Typo, please fix" + find(".js-comment-button").click + end + + wait_for_ajax + + expect(page).to have_content("Typo, please fix") + end + end end describe 'compare with older version' do before do page.within '.mr-version-compare-dropdown' do find('.btn-default').click - find(:link, 'version 1').trigger('click') + click_link 'version 1' + end + + # Wait for the page to load + page.within '.mr-version-compare-dropdown' do + expect(page).to have_content 'version 1' end end @@ -80,8 +111,43 @@ feature 'Merge Request versions', js: true, feature: true do end end - it 'show the message about disabled comments' do - expect(page).to have_content 'Comments are disabled' + it 'show the message about comments' do + expect(page).to have_content 'Not all comments are displayed' + end + + it 'shows comments that were last relevant at that version' do + position = Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: 4, + new_line: 4, + diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs + ) + outdated_diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + + visit current_url + wait_for_ajax + + expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']") + end + + it 'allows commenting' do + diff_file_selector = ".diff-file[id='7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44']" + line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_4_4' + + page.within(diff_file_selector) do + find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").trigger 'mouseover' + find(".line_holder[id='#{line_code}'] button").trigger 'click' + + page.within("form[data-line-code='#{line_code}']") do + fill_in "note[note]", with: "Typo, please fix" + find(".js-comment-button").click + end + + wait_for_ajax + + expect(page).to have_content("Typo, please fix") + end end it 'show diff between new and old version' do diff --git a/spec/features/milestones/milestones_spec.rb b/spec/features/milestones/milestones_spec.rb index 50d7ca39045..9eec3d7f270 100644 --- a/spec/features/milestones/milestones_spec.rb +++ b/spec/features/milestones/milestones_spec.rb @@ -86,6 +86,9 @@ describe 'Milestone draggable', feature: true, js: true do visit namespace_project_milestone_path(project.namespace, project, milestone) page.find("a[href='#tab-merge-requests']").click + + wait_for_ajax + scroll_into_view('.milestone-content') drag_to(selector: '.merge_requests-sortable-list', list_to_index: 1) diff --git a/spec/features/milestones/show_spec.rb b/spec/features/milestones/show_spec.rb index 40b4dc63697..227eb04ba72 100644 --- a/spec/features/milestones/show_spec.rb +++ b/spec/features/milestones/show_spec.rb @@ -5,7 +5,7 @@ describe 'Milestone show', feature: true do let(:project) { create(:empty_project) } let(:milestone) { create(:milestone, project: project) } let(:labels) { create_list(:label, 2, project: project) } - let(:issue_params) { { project: project, assignee: user, author: user, milestone: milestone, labels: labels } } + let(:issue_params) { { project: project, assignees: [user], author: user, milestone: milestone, labels: labels } } before do project.add_user(user, :developer) diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb new file mode 100644 index 00000000000..05a7587f8d4 --- /dev/null +++ b/spec/features/profiles/account_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +feature 'Profile > Account', feature: true do + given(:user) { create(:user, username: 'foo') } + + before do + login_as(user) + end + + describe 'Change username' do + given(:new_username) { 'bar' } + given(:new_user_path) { "/#{new_username}" } + given(:old_user_path) { "/#{user.username}" } + + scenario 'the user is accessible via the new path' do + update_username(new_username) + visit new_user_path + expect(current_path).to eq(new_user_path) + expect(find('.user-info')).to have_content(new_username) + end + + scenario 'the old user path redirects to the new path' do + update_username(new_username) + visit old_user_path + expect(current_path).to eq(new_user_path) + expect(find('.user-info')).to have_content(new_username) + end + + context 'with a project' do + given!(:project) { create(:project, namespace: user.namespace, path: 'project') } + given(:new_project_path) { "/#{new_username}/#{project.path}" } + given(:old_project_path) { "/#{user.username}/#{project.path}" } + + before(:context) { TestEnv.clean_test_path } + after(:example) { TestEnv.clean_test_path } + + scenario 'the project is accessible via the new path' do + update_username(new_username) + visit new_project_path + expect(current_path).to eq(new_project_path) + expect(find('h1.project-title')).to have_content(project.name) + end + + scenario 'the old project path redirects to the new path' do + update_username(new_username) + visit old_project_path + expect(current_path).to eq(new_project_path) + expect(find('h1.project-title')).to have_content(project.name) + end + end + end +end + +def update_username(new_username) + allow(user.namespace).to receive(:move_dir) + visit profile_account_path + fill_in 'user_username', with: new_username + click_button 'Update username' +end diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb new file mode 100644 index 00000000000..74308a7e8dd --- /dev/null +++ b/spec/features/projects/artifacts/file_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +feature 'Artifact file', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def visit_file(path) + visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path) + end + + context 'Text file' do + before do + visit_file('other_artifacts_0.1.2/doc_sample.txt') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # shows an error message + expect(page).to have_content('The source could not be displayed because it is stored as a job artifact. You can download it instead.') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') + end + end + end + + context 'JPG file' do + before do + visit_file('rails_sample.jpg') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows rendered image + expect(page).to have_selector('.image_file img') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') + end + end + end +end diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 8dba2ccbafa..5955623f565 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -159,7 +159,7 @@ feature 'File blob', :js, feature: true do expect(page).to have_selector('.blob-viewer[data-type="rich"]') # shows an error message - expect(page).to have_content('The rendered file could not be displayed because it is stored in LFS. You can view the source or download it instead.') + expect(page).to have_content('The rendered file could not be displayed because it is stored in LFS. You can download it instead.') # shows a viewer switcher expect(page).to have_selector('.js-blob-viewer-switcher') @@ -167,8 +167,8 @@ feature 'File blob', :js, feature: true do # does not show a copy button expect(page).not_to have_selector('.js-copy-blob-source-btn') - # shows a raw button - expect(page).to have_link('Open raw') + # shows a download button + expect(page).to have_link('Download') end end @@ -332,4 +332,41 @@ feature 'File blob', :js, feature: true do end end end + + context 'empty file' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add empty file", + file_path: 'files/empty.md', + file_content: '' + ).execute + + visit_blob('files/empty.md') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # shows an error message + expect(page).to have_content('Empty file') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # does not show a download or raw button + expect(page).not_to have_link('Download') + expect(page).not_to have_link('Open raw') + end + end + end end diff --git a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb new file mode 100644 index 00000000000..cfc782c98ad --- /dev/null +++ b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe 'New Branch Ref Dropdown', :js, :feature do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:toggle) { find('.create-from .dropdown-toggle') } + + before do + project.add_master(user) + + login_as(user) + visit new_namespace_project_branch_path(project.namespace, project) + end + + it 'filters a list of branches and tags' do + toggle.click + + filter_by('v1.0.0') + + expect(items_count).to be(1) + + filter_by('video') + + expect(items_count).to be(1) + + find('.create-from .dropdown-content li').click + + expect(toggle).to have_content 'video' + end + + it 'accepts a manually entered commit SHA' do + toggle.click + + filter_by('somecommitsha') + + find('.create-from input[type=search]').send_keys(:enter) + + expect(toggle).to have_content 'somecommitsha' + end + + def items_count + all('.create-from .dropdown-content li').length + end + + def filter_by(filter_text) + fill_in 'Filter by Git revision', with: filter_text + end +end diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb index 0b997f130ea..06abfbbc86b 100644 --- a/spec/features/projects/deploy_keys_spec.rb +++ b/spec/features/projects/deploy_keys_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Project deploy keys', feature: true do +describe 'Project deploy keys', :js, :feature do let(:user) { create(:user) } let(:project) { create(:project_empty_repo) } @@ -17,9 +17,13 @@ describe 'Project deploy keys', feature: true do it 'removes association between project and deploy key' do visit namespace_project_settings_repository_path(project.namespace, project) - page.within '.deploy-keys' do - expect { click_on 'Remove' } - .to change { project.deploy_keys.count }.by(-1) + page.within(find('.deploy-keys')) do + expect(page).to have_selector('.deploy-keys li', count: 1) + + click_on 'Remove' + + expect(page).not_to have_selector('.fa-spinner', count: 0) + expect(page).to have_selector('.deploy-keys li', count: 0) end end end diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index b080a8d500e..e1781cf320a 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -68,20 +68,23 @@ describe 'Edit Project Settings', feature: true do end describe 'project features visibility pages' do - before do - @tools = - { - builds: namespace_project_pipelines_path(project.namespace, project), - issues: namespace_project_issues_path(project.namespace, project), - wiki: namespace_project_wiki_path(project.namespace, project, :home), - snippets: namespace_project_snippets_path(project.namespace, project), - merge_requests: namespace_project_merge_requests_path(project.namespace, project), - } + let(:tools) do + { + builds: namespace_project_pipelines_path(project.namespace, project), + issues: namespace_project_issues_path(project.namespace, project), + wiki: namespace_project_wiki_path(project.namespace, project, :home), + snippets: namespace_project_snippets_path(project.namespace, project), + merge_requests: namespace_project_merge_requests_path(project.namespace, project), + } end context 'normal user' do + before do + login_as(member) + end + it 'renders 200 if tool is enabled' do - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::ENABLED) visit url expect(page.status_code).to eq(200) @@ -89,7 +92,7 @@ describe 'Edit Project Settings', feature: true do end it 'renders 404 if feature is disabled' do - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::DISABLED) visit url expect(page.status_code).to eq(404) @@ -99,21 +102,21 @@ describe 'Edit Project Settings', feature: true do it 'renders 404 if feature is enabled only for team members' do project.team.truncate - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) visit url expect(page.status_code).to eq(404) end end - it 'renders 200 if users is member of group' do + it 'renders 200 if user is member of group' do group = create(:group) project.group = group project.save group.add_owner(member) - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) visit url expect(page.status_code).to eq(200) @@ -128,7 +131,7 @@ describe 'Edit Project Settings', feature: true do end it 'renders 404 if feature is disabled' do - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::DISABLED) visit url expect(page.status_code).to eq(404) @@ -138,7 +141,7 @@ describe 'Edit Project Settings', feature: true do it 'renders 200 if feature is enabled only for team members' do project.team.truncate - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) visit url expect(page.status_code).to eq(200) diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb index d28a853bbc2..fa5e30075e3 100644 --- a/spec/features/projects/issuable_templates_spec.rb +++ b/spec/features/projects/issuable_templates_spec.rb @@ -12,7 +12,7 @@ feature 'issuable templates', feature: true, js: true do context 'user creates an issue using templates' do let(:template_content) { 'this is a test "bug" template' } let(:longtemplate_content) { %Q(this\n\n\n\n\nis\n\n\n\n\na\n\n\n\n\nbug\n\n\n\n\ntemplate) } - let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:issue) { create(:issue, author: user, assignees: [user], project: project) } let(:description_addition) { ' appending to description' } background do @@ -72,7 +72,7 @@ feature 'issuable templates', feature: true, js: true do context 'user creates an issue using templates, with a prior description' do let(:prior_description) { 'test issue description' } let(:template_content) { 'this is a test "bug" template' } - let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:issue) { create(:issue, author: user, assignees: [user], project: project) } background do project.repository.create_file( diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 5a53e48f5f8..cfac54ef259 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -254,4 +254,57 @@ describe 'Pipeline', :feature, :js do it { expect(build_manual.reload).to be_pending } end end + + describe 'GET /:project/pipelines/:id/failures' do + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } + let(:pipeline_failures_page) { failures_namespace_project_pipeline_path(project.namespace, project, pipeline) } + let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) } + + context 'with failed build' do + before do + failed_build.trace.set('4 examples, 1 failure') + + visit pipeline_failures_page + end + + it 'shows jobs tab pane as active' do + expect(page).to have_content('Failed Jobs') + expect(page).to have_css('#js-tab-failures.active') + end + + it 'lists failed builds' do + expect(page).to have_content(failed_build.name) + expect(page).to have_content(failed_build.stage) + end + + it 'shows build failure logs' do + expect(page).to have_content('4 examples, 1 failure') + end + end + + context 'when missing build logs' do + before do + visit pipeline_failures_page + end + + it 'includes failed jobs' do + expect(page).to have_content('No job trace') + end + end + + context 'without failures' do + before do + failed_build.update!(status: :success) + + visit pipeline_failures_page + end + + it 'displays the pipeline graph' do + expect(current_path).to eq(pipeline_path(pipeline)) + expect(page).not_to have_content('Failed Jobs') + expect(page).to have_selector('.pipeline-visualization') + end + end + end end diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb index 5d0314d5c09..11dcab4d737 100644 --- a/spec/features/projects/project_settings_spec.rb +++ b/spec/features/projects/project_settings_spec.rb @@ -1,64 +1,158 @@ require 'spec_helper' describe 'Edit Project Settings', feature: true do + include Select2Helper + let(:user) { create(:user) } - let(:project) { create(:empty_project, path: 'gitlab', name: 'sample') } + let(:project) { create(:empty_project, namespace: user.namespace, path: 'gitlab', name: 'sample') } before do login_as(user) - project.team << [user, :master] end - describe 'Project settings', js: true do + describe 'Project settings section', js: true do it 'shows errors for invalid project name' do visit edit_namespace_project_path(project.namespace, project) - fill_in 'project_name_edit', with: 'foo&bar' - click_button 'Save changes' - expect(page).to have_field 'project_name_edit', with: 'foo&bar' expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." expect(page).to have_button 'Save changes' end - scenario 'shows a successful notice when the project is updated' do + it 'shows a successful notice when the project is updated' do visit edit_namespace_project_path(project.namespace, project) - fill_in 'project_name_edit', with: 'hello world' - click_button 'Save changes' - expect(page).to have_content "Project 'hello world' was successfully updated." end end - describe 'Rename repository' do - it 'shows errors for invalid project path/name' do - visit edit_namespace_project_path(project.namespace, project) - - fill_in 'project_name', with: 'foo&bar' - fill_in 'Path', with: 'foo&bar' + describe 'Rename repository section' do + context 'with invalid characters' do + it 'shows errors for invalid project path/name' do + rename_project(project, name: 'foo&bar', path: 'foo&bar') + expect(page).to have_field 'Project name', with: 'foo&bar' + expect(page).to have_field 'Path', with: 'foo&bar' + expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." + expect(page).to have_content "Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'" + end + end - click_button 'Rename project' + context 'when changing project name' do + it 'renames the repository' do + rename_project(project, name: 'bar') + expect(find('h1.title')).to have_content(project.name) + end + + context 'with emojis' do + it 'shows error for invalid project name' do + rename_project(project, name: '🚀 foo bar ☁️') + expect(page).to have_field 'Project name', with: '🚀 foo bar ☁️' + expect(page).not_to have_content "Name can contain only letters, digits, emojis '_', '.', dash and space. It must start with letter, digit, emoji or '_'." + end + end + end - expect(page).to have_field 'Project name', with: 'foo&bar' - expect(page).to have_field 'Path', with: 'foo&bar' - expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." - expect(page).to have_content "Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'" + context 'when changing project path' do + # Not using empty project because we need a repo to exist + let(:project) { create(:project, namespace: user.namespace, name: 'gitlabhq') } + + before(:context) { TestEnv.clean_test_path } + after(:example) { TestEnv.clean_test_path } + + specify 'the project is accessible via the new path' do + rename_project(project, path: 'bar') + new_path = namespace_project_path(project.namespace, 'bar') + visit new_path + expect(current_path).to eq(new_path) + expect(find('h1.title')).to have_content(project.name) + end + + specify 'the project is accessible via a redirect from the old path' do + old_path = namespace_project_path(project.namespace, project) + rename_project(project, path: 'bar') + new_path = namespace_project_path(project.namespace, 'bar') + visit old_path + expect(current_path).to eq(new_path) + expect(find('h1.title')).to have_content(project.name) + end + + context 'and a new project is added with the same path' do + it 'overrides the redirect' do + old_path = namespace_project_path(project.namespace, project) + rename_project(project, path: 'bar') + new_project = create(:empty_project, namespace: user.namespace, path: 'gitlabhq', name: 'quz') + visit old_path + expect(current_path).to eq(old_path) + expect(find('h1.title')).to have_content(new_project.name) + end + end end end - describe 'Rename repository name with emojis' do - it 'shows error for invalid project name' do - visit edit_namespace_project_path(project.namespace, project) - - fill_in 'project_name', with: '🚀 foo bar ☁️' + describe 'Transfer project section', js: true do + # Not using empty project because we need a repo to exist + let!(:project) { create(:project, namespace: user.namespace, name: 'gitlabhq') } + let!(:group) { create(:group) } + + before(:context) { TestEnv.clean_test_path } + before(:example) { group.add_owner(user) } + after(:example) { TestEnv.clean_test_path } + + specify 'the project is accessible via the new path' do + transfer_project(project, group) + new_path = namespace_project_path(group, project) + visit new_path + expect(current_path).to eq(new_path) + expect(find('h1.title')).to have_content(project.name) + end - click_button 'Rename project' + specify 'the project is accessible via a redirect from the old path' do + old_path = namespace_project_path(project.namespace, project) + transfer_project(project, group) + new_path = namespace_project_path(group, project) + visit old_path + expect(current_path).to eq(new_path) + expect(find('h1.title')).to have_content(project.name) + end - expect(page).to have_field 'Project name', with: '🚀 foo bar ☁️' - expect(page).not_to have_content "Name can contain only letters, digits, emojis '_', '.', dash and space. It must start with letter, digit, emoji or '_'." + context 'and a new project is added with the same path' do + it 'overrides the redirect' do + old_path = namespace_project_path(project.namespace, project) + transfer_project(project, group) + new_project = create(:empty_project, namespace: user.namespace, path: 'gitlabhq', name: 'quz') + visit old_path + expect(current_path).to eq(old_path) + expect(find('h1.title')).to have_content(new_project.name) + end end end end + +def rename_project(project, name: nil, path: nil) + visit edit_namespace_project_path(project.namespace, project) + fill_in('project_name', with: name) if name + fill_in('Path', with: path) if path + click_button('Rename project') + wait_for_edit_project_page_reload + project.reload +end + +def transfer_project(project, namespace) + visit edit_namespace_project_path(project.namespace, project) + select2(namespace.id, from: '#new_namespace_id') + click_button('Transfer project') + confirm_transfer_modal + wait_for_edit_project_page_reload + project.reload +end + +def confirm_transfer_modal + fill_in('confirm_name_input', with: project.path) + click_button 'Confirm' +end + +def wait_for_edit_project_page_reload + expect(find('.project-edit-container')).to have_content('Rename repository') +end diff --git a/spec/features/protected_tags/access_control_ce_spec.rb b/spec/features/protected_tags/access_control_ce_spec.rb index 5b2baf8616c..a04fbcdd15f 100644 --- a/spec/features/protected_tags/access_control_ce_spec.rb +++ b/spec/features/protected_tags/access_control_ce_spec.rb @@ -10,7 +10,8 @@ RSpec.shared_examples "protected tags > access control > CE" do unless allowed_to_create_button.text == access_type_name allowed_to_create_button.click - within(".dropdown.open .dropdown-menu") { click_on access_type_name } + find('.create_access_levels-container .dropdown-menu li', match: :first) + within('.create_access_levels-container .dropdown-menu') { click_on access_type_name } end end diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb index e3aa87ded28..e68448467b0 100644 --- a/spec/features/protected_tags_spec.rb +++ b/spec/features/protected_tags_spec.rb @@ -11,6 +11,7 @@ feature 'Projected Tags', feature: true, js: true do find(".js-protected-tag-select").click find(".dropdown-input-field").set(tag_name) click_on("Create wildcard #{tag_name}") + find('.protected-tags-dropdown .dropdown-menu', visible: false) end describe "explicit protected tags" do diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb new file mode 100644 index 00000000000..e8fa49c18cb --- /dev/null +++ b/spec/features/raven_js_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +feature 'RavenJS', :feature, :js do + let(:raven_path) { '/raven.bundle.js' } + + it 'should not load raven if sentry is disabled' do + visit new_user_session_path + + expect(has_requested_raven).to eq(false) + end + + it 'should load raven if sentry is enabled' do + stub_application_setting(clientside_sentry_dsn: 'https://key@domain.com/id', clientside_sentry_enabled: true) + + visit new_user_session_path + + expect(has_requested_raven).to eq(true) + end + + def has_requested_raven + page.driver.network_traffic.one? {|request| request.url.end_with?(raven_path)} + end +end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index da6388dcdf2..498a4a5cba0 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -5,7 +5,7 @@ describe "Search", feature: true do let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } - let!(:issue) { create(:issue, project: project, assignee: user) } + let!(:issue) { create(:issue, project: project, assignees: [user]) } let!(:issue2) { create(:issue, project: project, author: user) } before do diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index a1a36931824..26879a77c48 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -466,6 +466,21 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/environments/:id/deployments" do + let(:environment) { create(:environment, project: project) } + subject { namespace_project_environment_deployments_path(project.namespace, project, environment) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/environments/new" do subject { new_namespace_project_environment_path(project.namespace, project) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index 5d58494a22a..699ca4f724c 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -449,6 +449,21 @@ describe "Private Project Access", feature: true do it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/environments/:id/deployments" do + let(:environment) { create(:environment, project: project) } + subject { namespace_project_environment_deployments_path(project.namespace, project, environment) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/environments/new" do subject { new_namespace_project_environment_path(project.namespace, project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 5df5b710dc4..624f0d0f485 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -286,6 +286,21 @@ describe "Public Project Access", feature: true do it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/environments/:id/deployments" do + let(:environment) { create(:environment, project: project) } + subject { namespace_project_environment_deployments_path(project.namespace, project, environment) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/environments/new" do subject { new_namespace_project_environment_path(project.namespace, project) } diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index c646039e0b1..957baac02eb 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Comments on personal snippets', feature: true do +describe 'Comments on personal snippets', :js, feature: true do let!(:user) { create(:user) } let!(:snippet) { create(:personal_snippet, :public) } let!(:snippet_notes) do @@ -18,7 +18,7 @@ describe 'Comments on personal snippets', feature: true do subject { page } - context 'viewing the snippet detail page' do + context 'when viewing the snippet detail page' do it 'contains notes for a snippet with correct action icons' do expect(page).to have_selector('#notes-list li', count: 2) @@ -36,4 +36,64 @@ describe 'Comments on personal snippets', feature: true do end end end + + context 'when submitting a note' do + it 'shows a valid form' do + is_expected.to have_css('.js-main-target-form', visible: true, count: 1) + expect(find('.js-main-target-form .js-comment-button').value). + to eq('Comment') + + page.within('.js-main-target-form') do + expect(page).not_to have_link('Cancel') + end + end + + it 'previews a note' do + fill_in 'note[note]', with: 'This is **awesome**!' + find('.js-md-preview-button').click + + page.within('.new-note .md-preview') do + expect(page).to have_content('This is awesome!') + expect(page).to have_selector('strong') + end + end + + it 'creates a note' do + fill_in 'note[note]', with: 'This is **awesome**!' + click_button 'Comment' + + expect(find('div#notes')).to have_content('This is awesome!') + end + end + + context 'when editing a note' do + it 'changes the text' do + page.within("#notes-list li#note_#{snippet_notes[0].id}") do + click_on 'Edit comment' + end + + page.within('.current-note-edit-form') do + fill_in 'note[note]', with: 'new content' + find('.btn-save').click + end + + page.within("#notes-list li#note_#{snippet_notes[0].id}") do + expect(page).to have_css('.note_edited_ago') + expect(page).to have_content('new content') + expect(find('.note_edited_ago').text).to match(/less than a minute ago/) + end + end + end + + context 'when deleting a note' do + it 'removes the note from the snippet detail page' do + page.within("#notes-list li#note_#{snippet_notes[0].id}") do + click_on 'Remove comment' + end + + wait_for_ajax + + expect(page).not_to have_selector("#notes-list li#note_#{snippet_notes[0].id}") + end + end end diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index c33692fc4a9..8bd13caf2b0 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -62,12 +62,15 @@ feature 'Task Lists', feature: true do visit namespace_project_issue_path(project.namespace, project, issue) end - describe 'for Issues' do - describe 'multiple tasks' do + describe 'for Issues', feature: true do + describe 'multiple tasks', js: true do + include WaitForVueResource + let!(:issue) { create(:issue, description: markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 6) @@ -76,25 +79,24 @@ feature 'Task Lists', feature: true do it 'contains the required selectors' do visit_issue(project, issue) + wait_for_vue_resource - container = '.detail-page-description .description.js-task-list-container' - - expect(page).to have_selector(container) - expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") - expect(page).to have_selector("#{container} .js-task-list-field") - expect(page).to have_selector('form.js-issuable-update') + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") expect(page).to have_selector('a.btn-close') end it 'is only editable by author' do visit_issue(project, issue) - expect(page).to have_selector('.js-task-list-container') + wait_for_vue_resource - logout(:user) + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") + logout(:user) login_as(user2) visit current_path - expect(page).not_to have_selector('.js-task-list-container') + wait_for_vue_resource + + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") end it 'provides a summary on Issues#index' do @@ -103,11 +105,14 @@ feature 'Task Lists', feature: true do end end - describe 'single incomplete task' do + describe 'single incomplete task', js: true do + include WaitForVueResource + let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -116,15 +121,18 @@ feature 'Task Lists', feature: true do it 'provides a summary on Issues#index' do visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("0 of 1 task completed") end end - describe 'single complete task' do + describe 'single complete task', js: true do + include WaitForVueResource let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -133,6 +141,7 @@ feature 'Task Lists', feature: true do it 'provides a summary on Issues#index' do visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("1 of 1 task completed") end end diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 81fa2de1cc3..783f330221c 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -104,6 +104,24 @@ feature 'Triggers', feature: true, js: true do expect(page).to have_content 'The form contains the following errors' end + + context 'when GitLab time_zone is ActiveSupport::TimeZone format' do + before do + allow(Time).to receive(:zone) + .and_return(ActiveSupport::TimeZone['Eastern Time (US & Canada)']) + end + + scenario 'do fill form with valid data and save' do + find('#trigger_trigger_schedule_attributes_active').click + fill_in 'trigger_trigger_schedule_attributes_cron', with: '1 * * * *' + fill_in 'trigger_trigger_schedule_attributes_cron_timezone', with: 'UTC' + fill_in 'trigger_trigger_schedule_attributes_ref', with: 'master' + click_button 'Save trigger' + + expect(page.find('.flash-notice')) + .to have_content 'Trigger was successfully updated.' + end + end end context 'disabling schedule' do diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index e2d9cfdd0b0..a23c4ca2b92 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -6,7 +6,7 @@ describe 'Unsubscribe links', feature: true do let(:recipient) { create(:user) } let(:author) { create(:user) } let(:project) { create(:empty_project, :public) } - let(:params) { { title: 'A bug!', description: 'Fix it!', assignee: recipient } } + let(:params) { { title: 'A bug!', description: 'Fix it!', assignees: [recipient] } } let(:issue) { Issues::CreateService.new(project, author, params).execute } let(:mail) { ActionMailer::Base.deliveries.last } diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index a1ae1d746af..96151689359 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -7,16 +7,16 @@ describe IssuesFinder do set(:project2) { create(:empty_project) } set(:milestone) { create(:milestone, project: project1) } set(:label) { create(:label, project: project2) } - set(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone, title: 'gitlab') } - set(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'gitlab') } - set(:issue3) { create(:issue, author: user2, assignee: user2, project: project2, title: 'tanuki', description: 'tanuki') } + set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab') } + set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') } + set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki') } describe '#execute' do - set(:closed_issue) { create(:issue, author: user2, assignee: user2, project: project2, state: 'closed') } + set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } set(:label_link) { create(:label_link, label: label, target: issue2) } let(:search_user) { user } let(:params) { {} } - let(:issues) { IssuesFinder.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } + let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } before(:context) do project1.team << [user, :master] @@ -91,7 +91,7 @@ describe IssuesFinder do before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) end end @@ -126,7 +126,7 @@ describe IssuesFinder do before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) end end @@ -282,15 +282,15 @@ describe IssuesFinder do let!(:confidential_issue) { create(:issue, project: project, confidential: true) } it 'returns non confidential issues for nil user' do - expect(IssuesFinder.send(:not_restricted_by_confidentiality, nil)).to include(public_issue) + expect(described_class.send(:not_restricted_by_confidentiality, nil)).to include(public_issue) end it 'returns non confidential issues for user not authorized for the issues projects' do - expect(IssuesFinder.send(:not_restricted_by_confidentiality, user)).to include(public_issue) + expect(described_class.send(:not_restricted_by_confidentiality, user)).to include(public_issue) end it 'returns all issues for user authorized for the issues projects' do - expect(IssuesFinder.send(:not_restricted_by_confidentiality, authorized_user)).to include(public_issue, confidential_issue) + expect(described_class.send(:not_restricted_by_confidentiality, authorized_user)).to include(public_issue, confidential_issue) end end end diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 21ef94ac5d1..58b7cd5e098 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -23,26 +23,26 @@ describe MergeRequestsFinder do describe "#execute" do it 'filters by scope' do params = { scope: 'authored', state: 'opened' } - merge_requests = MergeRequestsFinder.new(user, params).execute + merge_requests = described_class.new(user, params).execute expect(merge_requests.size).to eq(3) end it 'filters by project' do params = { project_id: project1.id, scope: 'authored', state: 'opened' } - merge_requests = MergeRequestsFinder.new(user, params).execute + merge_requests = described_class.new(user, params).execute expect(merge_requests.size).to eq(1) end it 'filters by non_archived' do params = { non_archived: true } - merge_requests = MergeRequestsFinder.new(user, params).execute + merge_requests = described_class.new(user, params).execute expect(merge_requests.size).to eq(3) end it 'filters by iid' do params = { project_id: project1.id, iids: merge_request1.iid } - merge_requests = MergeRequestsFinder.new(user, params).execute + merge_requests = described_class.new(user, params).execute expect(merge_requests).to contain_exactly(merge_request1) end diff --git a/spec/finders/pipelines_finder_spec.rb b/spec/finders/pipelines_finder_spec.rb index 6bada7b3eb9..f2aeda241c1 100644 --- a/spec/finders/pipelines_finder_spec.rb +++ b/spec/finders/pipelines_finder_spec.rb @@ -3,50 +3,205 @@ require 'spec_helper' describe PipelinesFinder do let(:project) { create(:project, :repository) } - let!(:tag_pipeline) { create(:ci_pipeline, project: project, ref: 'v1.0.0') } - let!(:branch_pipeline) { create(:ci_pipeline, project: project) } - - subject { described_class.new(project).execute(params) } + subject { described_class.new(project, params).execute } describe "#execute" do - context 'when a scope is passed' do - context 'when scope is nil' do - let(:params) { { scope: nil } } + context 'when params is empty' do + let(:params) { {} } + let!(:pipelines) { create_list(:ci_pipeline, 2, project: project) } + + it 'returns all pipelines' do + is_expected.to match_array(pipelines) + end + end + + %w[running pending].each do |target| + context "when scope is #{target}" do + let(:params) { { scope: target } } + let!(:pipeline) { create(:ci_pipeline, project: project, status: target) } - it 'selects all pipelines' do - expect(subject.count).to be 2 - expect(subject).to include tag_pipeline - expect(subject).to include branch_pipeline + it 'returns matched pipelines' do + is_expected.to eq([pipeline]) end end + end + + context 'when scope is finished' do + let(:params) { { scope: 'finished' } } + let!(:pipelines) do + [create(:ci_pipeline, project: project, status: 'success'), + create(:ci_pipeline, project: project, status: 'failed'), + create(:ci_pipeline, project: project, status: 'canceled')] + end - context 'when selecting branches' do + it 'returns matched pipelines' do + is_expected.to match_array(pipelines) + end + end + + context 'when scope is branches or tags' do + let!(:pipeline_branch) { create(:ci_pipeline, project: project) } + let!(:pipeline_tag) { create(:ci_pipeline, project: project, ref: 'v1.0.0', tag: true) } + + context 'when scope is branches' do let(:params) { { scope: 'branches' } } - it 'excludes tags' do - expect(subject).not_to include tag_pipeline - expect(subject).to include branch_pipeline + it 'returns matched pipelines' do + is_expected.to eq([pipeline_branch]) end end - context 'when selecting tags' do + context 'when scope is tags' do let(:params) { { scope: 'tags' } } - it 'excludes branches' do - expect(subject).to include tag_pipeline - expect(subject).not_to include branch_pipeline + it 'returns matched pipelines' do + is_expected.to eq([pipeline_tag]) + end + end + end + + HasStatus::AVAILABLE_STATUSES.each do |target| + context "when status is #{target}" do + let(:params) { { status: target } } + let!(:pipeline) { create(:ci_pipeline, project: project, status: target) } + + before do + exception_status = HasStatus::AVAILABLE_STATUSES - [target] + create(:ci_pipeline, project: project, status: exception_status.first) + end + + it 'returns matched pipelines' do + is_expected.to eq([pipeline]) end end end - # Scoping to pending will speed up the test as it doesn't hit the FS - let(:params) { { scope: 'pending' } } + context 'when ref is specified' do + let!(:pipeline) { create(:ci_pipeline, project: project) } + + context 'when ref exists' do + let(:params) { { ref: 'master' } } + + it 'returns matched pipelines' do + is_expected.to eq([pipeline]) + end + end + + context 'when ref does not exist' do + let(:params) { { ref: 'invalid-ref' } } + + it 'returns empty' do + is_expected.to be_empty + end + end + end + + context 'when name is specified' do + let(:user) { create(:user) } + let!(:pipeline) { create(:ci_pipeline, project: project, user: user) } + + context 'when name exists' do + let(:params) { { name: user.name } } + + it 'returns matched pipelines' do + is_expected.to eq([pipeline]) + end + end + + context 'when name does not exist' do + let(:params) { { name: 'invalid-name' } } + + it 'returns empty' do + is_expected.to be_empty + end + end + end - it 'orders in descending order on ID' do - feature_pipeline = create(:ci_pipeline, project: project, ref: 'feature') + context 'when username is specified' do + let(:user) { create(:user) } + let!(:pipeline) { create(:ci_pipeline, project: project, user: user) } - expected_ids = [feature_pipeline.id, branch_pipeline.id, tag_pipeline.id].sort.reverse - expect(subject.map(&:id)).to eq expected_ids + context 'when username exists' do + let(:params) { { username: user.username } } + + it 'returns matched pipelines' do + is_expected.to eq([pipeline]) + end + end + + context 'when username does not exist' do + let(:params) { { username: 'invalid-username' } } + + it 'returns empty' do + is_expected.to be_empty + end + end + end + + context 'when yaml_errors is specified' do + let!(:pipeline1) { create(:ci_pipeline, project: project, yaml_errors: 'Syntax error') } + let!(:pipeline2) { create(:ci_pipeline, project: project) } + + context 'when yaml_errors is true' do + let(:params) { { yaml_errors: true } } + + it 'returns matched pipelines' do + is_expected.to eq([pipeline1]) + end + end + + context 'when yaml_errors is false' do + let(:params) { { yaml_errors: false } } + + it 'returns matched pipelines' do + is_expected.to eq([pipeline2]) + end + end + + context 'when yaml_errors is invalid' do + let(:params) { { yaml_errors: "invalid-yaml_errors" } } + + it 'returns all pipelines' do + is_expected.to match_array([pipeline1, pipeline2]) + end + end + end + + context 'when order_by and sort are specified' do + context 'when order_by user_id' do + let(:params) { { order_by: 'user_id', sort: 'asc' } } + let!(:pipelines) { create_list(:ci_pipeline, 2, project: project, user: create(:user)) } + + it 'sorts as user_id: :asc' do + is_expected.to match_array(pipelines) + end + + context 'when sort is invalid' do + let(:params) { { order_by: 'user_id', sort: 'invalid_sort' } } + + it 'sorts as user_id: :desc' do + is_expected.to eq(pipelines.sort_by { |p| -p.user.id }) + end + end + end + + context 'when order_by is invalid' do + let(:params) { { order_by: 'invalid_column', sort: 'asc' } } + let!(:pipelines) { create_list(:ci_pipeline, 2, project: project) } + + it 'sorts as id: :asc' do + is_expected.to eq(pipelines.sort_by { |p| p.id }) + end + end + + context 'when both are nil' do + let(:params) { { order_by: nil, sort: nil } } + let!(:pipelines) { create_list(:ci_pipeline, 2, project: project) } + + it 'sorts as id: :desc' do + is_expected.to eq(pipelines.sort_by { |p| -p.id }) + end + end end end end diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb index 975e99c5807..cb6c80d1bd0 100644 --- a/spec/finders/snippets_finder_spec.rb +++ b/spec/finders/snippets_finder_spec.rb @@ -14,13 +14,13 @@ describe SnippetsFinder do let!(:snippet3) { create(:personal_snippet, :public) } it "returns all private and internal snippets" do - snippets = SnippetsFinder.new.execute(user, filter: :all) + snippets = described_class.new.execute(user, filter: :all) expect(snippets).to include(snippet2, snippet3) expect(snippets).not_to include(snippet1) end it "returns all public snippets" do - snippets = SnippetsFinder.new.execute(nil, filter: :all) + snippets = described_class.new.execute(nil, filter: :all) expect(snippets).to include(snippet3) expect(snippets).not_to include(snippet1, snippet2) end @@ -32,7 +32,7 @@ describe SnippetsFinder do let!(:snippet3) { create(:personal_snippet, :public) } it "returns public public snippets" do - snippets = SnippetsFinder.new.execute(nil, filter: :public) + snippets = described_class.new.execute(nil, filter: :public) expect(snippets).to include(snippet3) expect(snippets).not_to include(snippet1, snippet2) @@ -45,36 +45,36 @@ describe SnippetsFinder do let!(:snippet3) { create(:personal_snippet, :public, author: user) } it "returns all public and internal snippets" do - snippets = SnippetsFinder.new.execute(user1, filter: :by_user, user: user) + snippets = described_class.new.execute(user1, filter: :by_user, user: user) expect(snippets).to include(snippet2, snippet3) expect(snippets).not_to include(snippet1) end it "returns internal snippets" do - snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_internal") + snippets = described_class.new.execute(user, filter: :by_user, user: user, scope: "are_internal") expect(snippets).to include(snippet2) expect(snippets).not_to include(snippet1, snippet3) end it "returns private snippets" do - snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_private") + snippets = described_class.new.execute(user, filter: :by_user, user: user, scope: "are_private") expect(snippets).to include(snippet1) expect(snippets).not_to include(snippet2, snippet3) end it "returns public snippets" do - snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_public") + snippets = described_class.new.execute(user, filter: :by_user, user: user, scope: "are_public") expect(snippets).to include(snippet3) expect(snippets).not_to include(snippet1, snippet2) end it "returns all snippets" do - snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user) + snippets = described_class.new.execute(user, filter: :by_user, user: user) expect(snippets).to include(snippet1, snippet2, snippet3) end it "returns only public snippets if unauthenticated user" do - snippets = SnippetsFinder.new.execute(nil, filter: :by_user, user: user) + snippets = described_class.new.execute(nil, filter: :by_user, user: user) expect(snippets).to include(snippet3) expect(snippets).not_to include(snippet2, snippet1) end @@ -88,43 +88,43 @@ describe SnippetsFinder do end it "returns public snippets for unauthorized user" do - snippets = SnippetsFinder.new.execute(nil, filter: :by_project, project: project1) + snippets = described_class.new.execute(nil, filter: :by_project, project: project1) expect(snippets).to include(@snippet3) expect(snippets).not_to include(@snippet1, @snippet2) end it "returns public and internal snippets for non project members" do - snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1) + snippets = described_class.new.execute(user, filter: :by_project, project: project1) expect(snippets).to include(@snippet2, @snippet3) expect(snippets).not_to include(@snippet1) end it "returns public snippets for non project members" do - snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_public") + snippets = described_class.new.execute(user, filter: :by_project, project: project1, scope: "are_public") expect(snippets).to include(@snippet3) expect(snippets).not_to include(@snippet1, @snippet2) end it "returns internal snippets for non project members" do - snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_internal") + snippets = described_class.new.execute(user, filter: :by_project, project: project1, scope: "are_internal") expect(snippets).to include(@snippet2) expect(snippets).not_to include(@snippet1, @snippet3) end it "does not return private snippets for non project members" do - snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_private") + snippets = described_class.new.execute(user, filter: :by_project, project: project1, scope: "are_private") expect(snippets).not_to include(@snippet1, @snippet2, @snippet3) end it "returns all snippets for project members" do project1.team << [user, :developer] - snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1) + snippets = described_class.new.execute(user, filter: :by_project, project: project1) expect(snippets).to include(@snippet1, @snippet2, @snippet3) end it "returns private snippets for project members" do project1.team << [user, :developer] - snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_private") + snippets = described_class.new.execute(user, filter: :by_project, project: project1, scope: "are_private") expect(snippets).to include(@snippet1) end end diff --git a/spec/fixtures/api/schemas/branch.json b/spec/fixtures/api/schemas/branch.json new file mode 100644 index 00000000000..0bb74577010 --- /dev/null +++ b/spec/fixtures/api/schemas/branch.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required" : [ + "name", + "url" + ], + "properties" : { + "name": { "type": "string" }, + "url": { "type": "uri" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/deployments.json b/spec/fixtures/api/schemas/deployments.json new file mode 100644 index 00000000000..1112f23aab2 --- /dev/null +++ b/spec/fixtures/api/schemas/deployments.json @@ -0,0 +1,58 @@ +{ + "additionalProperties": false, + "properties": { + "deployments": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "iid": { + "type": "integer" + }, + "last?": { + "type": "boolean" + }, + "ref": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "sha": { + "type": "string" + }, + "tag": { + "type": "boolean" + } + }, + "required": [ + "sha", + "created_at", + "iid", + "tag", + "last?", + "ref", + "id" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "deployments" + ], + "type": "object" +} diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index 21c078e0f44..ff86437fdd5 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -46,6 +46,24 @@ "username": { "type": "string" }, "avatar_url": { "type": "uri" } }, + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "required": [ + "id", + "name", + "username", + "avatar_url" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "avatar_url": { "type": "uri" } + } + } + }, "subscribed": { "type": ["boolean", "null"] } }, "additionalProperties": false diff --git a/spec/fixtures/api/schemas/merge_request.json b/spec/fixtures/api/schemas/merge_request.json new file mode 100644 index 00000000000..36962660cd9 --- /dev/null +++ b/spec/fixtures/api/schemas/merge_request.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required" : [ + "iid", + "url" + ], + "properties" : { + "iid": { "type": "integer" }, + "url": { "type": "uri" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/pipeline.json b/spec/fixtures/api/schemas/pipeline.json new file mode 100644 index 00000000000..55511d17b5e --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "properties": { + "commit": { + "id": "/properties/commit", + "properties": { + "author": { + "id": "/properties/commit/properties/author", + "type": "null" + }, + "author_email": { + "id": "/properties/commit/properties/author_email", + "type": "string" + }, + "author_gravatar_url": { + "id": "/properties/commit/properties/author_gravatar_url", + "type": "string" + }, + "author_name": { + "id": "/properties/commit/properties/author_name", + "type": "string" + }, + "authored_date": { + "id": "/properties/commit/properties/authored_date", + "type": "string" + }, + "commit_path": { + "id": "/properties/commit/properties/commit_path", + "type": "string" + }, + "commit_url": { + "id": "/properties/commit/properties/commit_url", + "type": "string" + }, + "committed_date": { + "id": "/properties/commit/properties/committed_date", + "type": "string" + }, + "committer_email": { + "id": "/properties/commit/properties/committer_email", + "type": "string" + }, + "committer_name": { + "id": "/properties/commit/properties/committer_name", + "type": "string" + }, + "created_at": { + "id": "/properties/commit/properties/created_at", + "type": "string" + }, + "id": { + "id": "/properties/commit/properties/id", + "type": "string" + }, + "message": { + "id": "/properties/commit/properties/message", + "type": "string" + }, + "parent_ids": { + "id": "/properties/commit/properties/parent_ids", + "items": { + "id": "/properties/commit/properties/parent_ids/items", + "type": "string" + }, + "type": "array" + }, + "short_id": { + "id": "/properties/commit/properties/short_id", + "type": "string" + }, + "title": { + "id": "/properties/commit/properties/title", + "type": "string" + } + }, + "type": "object" + }, + "created_at": { + "id": "/properties/created_at", + "type": "string" + }, + "details": { + "id": "/properties/details", + "properties": { + "artifacts": { + "id": "/properties/details/properties/artifacts", + "items": {}, + "type": "array" + }, + "duration": { + "id": "/properties/details/properties/duration", + "type": "integer" + }, + "finished_at": { + "id": "/properties/details/properties/finished_at", + "type": "string" + }, + "manual_actions": { + "id": "/properties/details/properties/manual_actions", + "items": {}, + "type": "array" + }, + "stages": { + "id": "/properties/details/properties/stages", + "items": { + "id": "/properties/details/properties/stages/items", + "properties": { + "dropdown_path": { + "id": "/properties/details/properties/stages/items/properties/dropdown_path", + "type": "string" + }, + "groups": { + "id": "/properties/details/properties/stages/items/properties/groups", + "items": { + "id": "/properties/details/properties/stages/items/properties/groups/items", + "properties": { + "name": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/name", + "type": "string" + }, + "size": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/size", + "type": "integer" + }, + "status": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/details_path", + "type": "null" + }, + "favicon": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "name": { + "id": "/properties/details/properties/stages/items/properties/name", + "type": "string" + }, + "path": { + "id": "/properties/details/properties/stages/items/properties/path", + "type": "string" + }, + "status": { + "id": "/properties/details/properties/stages/items/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/stages/items/properties/status/properties/details_path", + "type": "string" + }, + "favicon": { + "id": "/properties/details/properties/stages/items/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/stages/items/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/stages/items/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/stages/items/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/stages/items/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/stages/items/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + }, + "title": { + "id": "/properties/details/properties/stages/items/properties/title", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "status": { + "id": "/properties/details/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/status/properties/details_path", + "type": "string" + }, + "favicon": { + "id": "/properties/details/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "flags": { + "id": "/properties/flags", + "properties": { + "cancelable": { + "id": "/properties/flags/properties/cancelable", + "type": "boolean" + }, + "latest": { + "id": "/properties/flags/properties/latest", + "type": "boolean" + }, + "retryable": { + "id": "/properties/flags/properties/retryable", + "type": "boolean" + }, + "stuck": { + "id": "/properties/flags/properties/stuck", + "type": "boolean" + }, + "triggered": { + "id": "/properties/flags/properties/triggered", + "type": "boolean" + }, + "yaml_errors": { + "id": "/properties/flags/properties/yaml_errors", + "type": "boolean" + } + }, + "type": "object" + }, + "id": { + "id": "/properties/id", + "type": "integer" + }, + "path": { + "id": "/properties/path", + "type": "string" + }, + "ref": { + "id": "/properties/ref", + "properties": { + "branch": { + "id": "/properties/ref/properties/branch", + "type": "boolean" + }, + "name": { + "id": "/properties/ref/properties/name", + "type": "string" + }, + "path": { + "id": "/properties/ref/properties/path", + "type": "string" + }, + "tag": { + "id": "/properties/ref/properties/tag", + "type": "boolean" + } + }, + "type": "object" + }, + "retry_path": { + "id": "/properties/retry_path", + "type": "string" + }, + "updated_at": { + "id": "/properties/updated_at", + "type": "string" + }, + "user": { + "id": "/properties/user", + "properties": { + "avatar_url": { + "id": "/properties/user/properties/avatar_url", + "type": "string" + }, + "id": { + "id": "/properties/user/properties/id", + "type": "integer" + }, + "name": { + "id": "/properties/user/properties/name", + "type": "string" + }, + "state": { + "id": "/properties/user/properties/state", + "type": "string" + }, + "username": { + "id": "/properties/user/properties/username", + "type": "string" + }, + "web_url": { + "id": "/properties/user/properties/web_url", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" +} diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json index 52199e75734..2d1c84ee93d 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issues.json +++ b/spec/fixtures/api/schemas/public_api/v4/issues.json @@ -33,6 +33,21 @@ }, "additionalProperties": false }, + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, "assignee": { "type": ["object", "null"], "properties": { @@ -67,7 +82,7 @@ "required": [ "id", "iid", "project_id", "title", "description", "state", "created_at", "updated_at", "labels", - "milestone", "assignee", "author", "user_notes_count", + "milestone", "assignees", "author", "user_notes_count", "upvotes", "downvotes", "due_date", "confidential", "web_url" ], diff --git a/spec/fixtures/emails/forwarded_new_issue.eml b/spec/fixtures/emails/forwarded_new_issue.eml new file mode 100644 index 00000000000..258106bb897 --- /dev/null +++ b/spec/fixtures/emails/forwarded_new_issue.eml @@ -0,0 +1,25 @@ +Delivered-To: incoming+gitlabhq/gitlabhq+auth_token@appmail.adventuretime.ooo +Return-Path: <jake@adventuretime.ooo> +Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +Date: Thu, 13 Jun 2013 17:03:48 -0400 +From: Jake the Dog <jake@adventuretime.ooo> +Delivered-To: support@adventuretime.ooo +To: support@adventuretime.ooo +Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com> +Subject: New Issue by email +Mime-Version: 1.0 +Content-Type: text/plain; + charset=ISO-8859-1 +Content-Transfer-Encoding: 7bit +X-Sieve: CMU Sieve 2.2 +X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, + 13 Jun 2013 14:03:48 -0700 (PDT) +X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 + +The reply by email functionality should be extended to allow creating a new issue by email. + +* Allow an admin to specify which project the issue should be created under by checking the sender domain. +* Possibly allow the use of regular expression matches within the subject/body to specify which project the issue should be created under. diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 075f1887d91..1b4393e6167 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -145,7 +145,7 @@ describe BlobHelper do end end - context 'for error :server_side_but_stored_in_lfs' do + context 'for error :server_side_but_stored_externally' do let(:blob) { fake_blob(lfs: true) } it 'returns an error message' do @@ -183,40 +183,56 @@ describe BlobHelper do expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) end end - end - context 'when the viewer is rich' do - context 'the blob is rendered as text' do - let(:blob) { fake_blob(path: 'file.md', lfs: true) } + context 'when the viewer is rich' do + context 'the blob is rendered as text' do + let(:blob) { fake_blob(path: 'file.md', size: 2.megabytes) } + + it 'includes a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).to include(/view the source/) + end + end + + context 'the blob is not rendered as text' do + let(:blob) { fake_blob(path: 'file.pdf', binary: true, size: 2.megabytes) } - it 'includes a "view the source" link' do - expect(helper.blob_render_error_options(viewer)).to include(/view the source/) + it 'does not include a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) + end end end - context 'the blob is not rendered as text' do - let(:blob) { fake_blob(path: 'file.pdf', binary: true, lfs: true) } + context 'when the viewer is not rich' do + before do + viewer_class.type = :simple + end + + let(:blob) { fake_blob(path: 'file.md', size: 2.megabytes) } it 'does not include a "view the source" link' do expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) end end - end - context 'when the viewer is not rich' do - before do - viewer_class.type = :simple + it 'includes a "download it" link' do + expect(helper.blob_render_error_options(viewer)).to include(/download it/) end + end + context 'for error :server_side_but_stored_externally' do let(:blob) { fake_blob(path: 'file.md', lfs: true) } + it 'does not include a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) + end + it 'does not include a "view the source" link' do expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) end - end - it 'includes a "download it" link' do - expect(helper.blob_render_error_options(viewer)).to include(/download it/) + it 'includes a "download it" link' do + expect(helper.blob_render_error_options(viewer)).to include(/download it/) + end end end end diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 93bb711f29a..c1ecb46aece 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -4,6 +4,23 @@ describe IssuablesHelper do let(:label) { build_stubbed(:label) } let(:label2) { build_stubbed(:label) } + describe '#users_dropdown_label' do + let(:user) { build_stubbed(:user) } + let(:user2) { build_stubbed(:user) } + + it 'returns unassigned' do + expect(users_dropdown_label([])).to eq('Unassigned') + end + + it 'returns selected user\'s name' do + expect(users_dropdown_label([user])).to eq(user.name) + end + + it 'returns selected user\'s name and counter' do + expect(users_dropdown_label([user, user2])).to eq("#{user.name} + 1 more") + end + end + describe '#issuable_labels_tooltip' do it 'returns label text' do expect(issuable_labels_tooltip([label])).to eq(label.title) diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index c10f4b09b5b..2a0de0b0656 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -111,7 +111,7 @@ describe MarkupHelper do it 'replaces commit message with emoji to link' do actual = link_to_gfm(':book: Book', '/foo') expect(actual). - to eq '<gl-emoji data-name="book" data-unicode-version="6.0">📖</gl-emoji><a href="/foo"> Book</a>' + to eq '<gl-emoji title="open book" data-name="book" data-unicode-version="6.0">📖</gl-emoji><a href="/foo"> Book</a>' end end diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index a427de32c4c..099146678ae 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -1,6 +1,8 @@ require "spec_helper" describe NotesHelper do + include RepoHelpers + let(:owner) { create(:owner) } let(:group) { create(:group) } let(:project) { create(:empty_project, namespace: group) } @@ -36,4 +38,216 @@ describe NotesHelper do expect(helper.note_max_access_for_user(other_note)).to eq('Reporter') end end + + describe '#discussion_path' do + let(:project) { create(:project) } + + context 'for a merge request discusion' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project, importing: true) } + let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) } + let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + + context 'for a diff discussion' do + context 'when the discussion is active' do + let(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion } + + it 'returns the diff path with the line code' do + expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: discussion.line_code)) + end + end + + context 'when the discussion is on an older merge request version' do + let(:position) do + Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: nil, + new_line: 4, + diff_refs: merge_request_diff1.diff_refs + ) + end + + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) } + let(:discussion) { diff_note.to_discussion } + + before do + diff_note.position = diff_note.original_position + diff_note.save! + end + + it 'returns the diff version path with the line code' do + expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, diff_id: merge_request_diff1, anchor: discussion.line_code)) + end + end + + context 'when the discussion is on a comparison between merge request versions' do + let(:position) do + Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: 4, + new_line: 4, + diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs + ) + end + + let(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position).to_discussion } + + it 'returns the diff version comparison path with the line code' do + expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, diff_id: merge_request_diff3, start_sha: merge_request_diff1.head_commit_sha, anchor: discussion.line_code)) + end + end + + context 'when the discussion does not have a merge request version' do + let(:outdated_diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, diff_refs: project.commit(sample_commit.id).diff_refs) } + let(:discussion) { outdated_diff_note.to_discussion } + + before do + outdated_diff_note.position = outdated_diff_note.original_position + outdated_diff_note.save! + end + + it 'returns nil' do + expect(helper.discussion_path(discussion)).to be_nil + end + end + end + + context 'for a legacy diff discussion' do + let(:discussion) { create(:legacy_diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion } + + context 'when the discussion is active' do + before do + allow(discussion).to receive(:active?).and_return(true) + end + + it 'returns the diff path with the line code' do + expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: discussion.line_code)) + end + end + + context 'when the discussion is outdated' do + before do + allow(discussion).to receive(:active?).and_return(false) + end + + it 'returns nil' do + expect(helper.discussion_path(discussion)).to be_nil + end + end + end + + context 'for a non-diff discussion' do + let(:discussion) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project).to_discussion } + + it 'returns nil' do + expect(helper.discussion_path(discussion)).to be_nil + end + end + end + + context 'for a commit discussion' do + let(:commit) { discussion.noteable } + + context 'for a diff discussion' do + let(:discussion) { create(:diff_note_on_commit, project: project).to_discussion } + + it 'returns the commit path with the line code' do + expect(helper.discussion_path(discussion)).to eq(namespace_project_commit_path(project.namespace, project, commit, anchor: discussion.line_code)) + end + end + + context 'for a legacy diff discussion' do + let(:discussion) { create(:legacy_diff_note_on_commit, project: project).to_discussion } + + it 'returns the commit path with the line code' do + expect(helper.discussion_path(discussion)).to eq(namespace_project_commit_path(project.namespace, project, commit, anchor: discussion.line_code)) + end + end + + context 'for a non-diff discussion' do + let(:discussion) { create(:discussion_note_on_commit, project: project).to_discussion } + + it 'returns the commit path' do + expect(helper.discussion_path(discussion)).to eq(namespace_project_commit_path(project.namespace, project, commit)) + end + end + end + end + + describe '#notes_url' do + it 'return snippet notes path for personal snippet' do + @snippet = create(:personal_snippet) + + expect(helper.notes_url).to eq("/snippets/#{@snippet.id}/notes") + end + + it 'return project notes path for project snippet' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + @snippet = create(:project_snippet, project: @project) + @noteable = @snippet + + expect(helper.notes_url).to eq("/nm/test/noteable/project_snippet/#{@noteable.id}/notes") + end + + it 'return project notes path for other noteables' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + @noteable = create(:issue, project: @project) + + expect(helper.notes_url).to eq("/nm/test/noteable/issue/#{@noteable.id}/notes") + end + end + + describe '#note_url' do + it 'return snippet notes path for personal snippet' do + note = create(:note_on_personal_snippet) + + expect(helper.note_url(note)).to eq("/snippets/#{note.noteable.id}/notes/#{note.id}") + end + + it 'return project notes path for project snippet' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + note = create(:note_on_project_snippet, project: @project) + + expect(helper.note_url(note)).to eq("/nm/test/notes/#{note.id}") + end + + it 'return project notes path for other noteables' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + note = create(:note_on_issue, project: @project) + + expect(helper.note_url(note)).to eq("/nm/test/notes/#{note.id}") + end + end + + describe '#form_resurces' do + it 'returns note for personal snippet' do + @snippet = create(:personal_snippet) + @note = create(:note_on_personal_snippet) + + expect(helper.form_resources).to eq([@note]) + end + + it 'returns namespace, project and note for project snippet' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + @snippet = create(:project_snippet, project: @project) + @note = create(:note_on_personal_snippet) + + expect(helper.form_resources).to eq([@project.namespace, @project, @note]) + end + + it 'returns namespace, project and note path for other noteables' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + @note = create(:note_on_issue, project: @project) + + expect(helper.form_resources).to eq([@project.namespace, @project, @note]) + end + end end diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js new file mode 100644 index 00000000000..9f9acc392c2 --- /dev/null +++ b/spec/javascripts/autosave_spec.js @@ -0,0 +1,134 @@ +import Autosave from '~/autosave'; +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('Autosave', () => { + let autosave; + + describe('class constructor', () => { + const key = 'key'; + const field = jasmine.createSpyObj('field', ['data', 'on']); + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); + spyOn(Autosave.prototype, 'restore'); + + autosave = new Autosave(field, key); + }); + + it('should set .isLocalStorageAvailable', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(autosave.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('restore', () => { + const key = 'key'; + const field = jasmine.createSpyObj('field', ['trigger']); + + beforeEach(() => { + autosave = { + field, + key, + }; + + spyOn(window.localStorage, 'getItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.restore.call(autosave); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.restore.call(autosave); + }); + + it('should call .getItem', () => { + expect(window.localStorage.getItem).toHaveBeenCalledWith(key); + }); + }); + }); + + describe('save', () => { + const field = jasmine.createSpyObj('field', ['val']); + + beforeEach(() => { + autosave = jasmine.createSpyObj('autosave', ['reset']); + autosave.field = field; + + field.val.and.returnValue('value'); + + spyOn(window.localStorage, 'setItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.save.call(autosave); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.save.call(autosave); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalled(); + }); + }); + }); + + describe('reset', () => { + const key = 'key'; + + beforeEach(() => { + autosave = { + key, + }; + + spyOn(window.localStorage, 'removeItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.reset.call(autosave); + }); + + it('should not call .removeItem', () => { + expect(window.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.reset.call(autosave); + }); + + it('should call .removeItem', () => { + expect(window.localStorage.removeItem).toHaveBeenCalledWith(key); + }); + }); + }); +}); diff --git a/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js new file mode 100644 index 00000000000..1ed96a67478 --- /dev/null +++ b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js @@ -0,0 +1,47 @@ +import { getUnicodeSupportMap } from '~/behaviors/gl_emoji/unicode_support_map'; +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('Unicode Support Map', () => { + describe('getUnicodeSupportMap', () => { + const stringSupportMap = 'stringSupportMap'; + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + spyOn(window.localStorage, 'getItem'); + spyOn(window.localStorage, 'setItem'); + spyOn(JSON, 'parse'); + spyOn(JSON, 'stringify').and.returnValue(stringSupportMap); + }); + + describe('if isLocalStorageAvailable is `true`', function () { + beforeEach(() => { + AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(true); + + getUnicodeSupportMap(); + }); + + it('should call .getItem and .setItem', () => { + const allArgs = window.localStorage.setItem.calls.allArgs(); + + expect(window.localStorage.getItem).toHaveBeenCalledWith('gl-emoji-user-agent'); + expect(allArgs[0][0]).toBe('gl-emoji-user-agent'); + expect(allArgs[0][1]).toBe(navigator.userAgent); + expect(allArgs[1][0]).toBe('gl-emoji-unicode-support-map'); + expect(allArgs[1][1]).toBe(stringSupportMap); + }); + }); + + describe('if isLocalStorageAvailable is `false`', function () { + beforeEach(() => { + AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(false); + + getUnicodeSupportMap(); + }); + + it('should not call .getItem or .setItem', () => { + expect(window.localStorage.getItem.calls.count()).toBe(1); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js new file mode 100644 index 00000000000..85816ee1f11 --- /dev/null +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -0,0 +1,342 @@ +import sqljs from 'sql.js'; +import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; +import ClassSpecHelper from '../../helpers/class_spec_helper'; + +describe('BalsamiqViewer', () => { + let balsamiqViewer; + let endpoint; + let viewer; + + describe('class constructor', () => { + beforeEach(() => { + endpoint = 'endpoint'; + viewer = { + dataset: { + endpoint, + }, + }; + + balsamiqViewer = new BalsamiqViewer(viewer); + }); + + it('should set .viewer', () => { + expect(balsamiqViewer.viewer).toBe(viewer); + }); + + it('should set .endpoint', () => { + expect(balsamiqViewer.endpoint).toBe(endpoint); + }); + }); + + describe('loadFile', () => { + let xhr; + + beforeEach(() => { + endpoint = 'endpoint'; + xhr = jasmine.createSpyObj('xhr', ['open', 'send']); + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']); + balsamiqViewer.endpoint = endpoint; + + spyOn(window, 'XMLHttpRequest').and.returnValue(xhr); + + BalsamiqViewer.prototype.loadFile.call(balsamiqViewer); + }); + + it('should call .open', () => { + expect(xhr.open).toHaveBeenCalledWith('GET', endpoint, true); + }); + + it('should set .responseType', () => { + expect(xhr.responseType).toBe('arraybuffer'); + }); + + it('should call .send', () => { + expect(xhr.send).toHaveBeenCalled(); + }); + }); + + describe('renderFile', () => { + let container; + let loadEvent; + let previews; + + beforeEach(() => { + loadEvent = { target: { response: {} } }; + viewer = jasmine.createSpyObj('viewer', ['appendChild']); + previews = [document.createElement('ul'), document.createElement('ul')]; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['initDatabase', 'getPreviews', 'renderPreview']); + balsamiqViewer.viewer = viewer; + + balsamiqViewer.getPreviews.and.returnValue(previews); + balsamiqViewer.renderPreview.and.callFake(preview => preview); + viewer.appendChild.and.callFake((containerElement) => { + container = containerElement; + }); + + BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, loadEvent); + }); + + it('should call .initDatabase', () => { + expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(loadEvent.target.response); + }); + + it('should call .getPreviews', () => { + expect(balsamiqViewer.getPreviews).toHaveBeenCalled(); + }); + + it('should call .renderPreview for each preview', () => { + const allArgs = balsamiqViewer.renderPreview.calls.allArgs(); + + expect(allArgs.length).toBe(2); + + previews.forEach((preview, i) => { + expect(allArgs[i][0]).toBe(preview); + }); + }); + + it('should set the container HTML', () => { + expect(container.innerHTML).toBe('<ul></ul><ul></ul>'); + }); + + it('should add inline preview classes', () => { + expect(container.classList[0]).toBe('list-inline'); + expect(container.classList[1]).toBe('previews'); + }); + + it('should call viewer.appendChild', () => { + expect(viewer.appendChild).toHaveBeenCalledWith(container); + }); + }); + + describe('initDatabase', () => { + let database; + let uint8Array; + let data; + + beforeEach(() => { + uint8Array = {}; + database = {}; + data = 'data'; + + balsamiqViewer = {}; + + spyOn(window, 'Uint8Array').and.returnValue(uint8Array); + spyOn(sqljs, 'Database').and.returnValue(database); + + BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data); + }); + + it('should instantiate Uint8Array', () => { + expect(window.Uint8Array).toHaveBeenCalledWith(data); + }); + + it('should call sqljs.Database', () => { + expect(sqljs.Database).toHaveBeenCalledWith(uint8Array); + }); + + it('should set .database', () => { + expect(balsamiqViewer.database).toBe(database); + }); + }); + + describe('getPreviews', () => { + let database; + let thumbnails; + let getPreviews; + + beforeEach(() => { + database = jasmine.createSpyObj('database', ['exec']); + thumbnails = [{ values: [0, 1, 2] }]; + + balsamiqViewer = { + database, + }; + + spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString()); + database.exec.and.returnValue(thumbnails); + + getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails'); + }); + + it('should call .parsePreview for each value', () => { + const allArgs = BalsamiqViewer.parsePreview.calls.allArgs(); + + expect(allArgs.length).toBe(3); + + thumbnails[0].values.forEach((value, i) => { + expect(allArgs[i][0]).toBe(value); + }); + }); + + it('should return an array of parsed values', () => { + expect(getPreviews).toEqual(['0', '1', '2']); + }); + }); + + describe('getResource', () => { + let database; + let resourceID; + let resource; + let getResource; + + beforeEach(() => { + database = jasmine.createSpyObj('database', ['exec']); + resourceID = 4; + resource = ['resource']; + + balsamiqViewer = { + database, + }; + + database.exec.and.returnValue(resource); + + getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith(`SELECT * FROM resources WHERE id = '${resourceID}'`); + }); + + it('should return the selected resource', () => { + expect(getResource).toBe(resource[0]); + }); + }); + + describe('renderPreview', () => { + let previewElement; + let innerHTML; + let preview; + let renderPreview; + + beforeEach(() => { + innerHTML = '<a>innerHTML</a>'; + previewElement = { + outerHTML: '<p>outerHTML</p>', + classList: jasmine.createSpyObj('classList', ['add']), + }; + preview = {}; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']); + + spyOn(document, 'createElement').and.returnValue(previewElement); + balsamiqViewer.renderTemplate.and.returnValue(innerHTML); + + renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); + }); + + it('should call classList.add', () => { + expect(previewElement.classList.add).toHaveBeenCalledWith('preview'); + }); + + it('should call .renderTemplate', () => { + expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview); + }); + + it('should set .innerHTML', () => { + expect(previewElement.innerHTML).toBe(innerHTML); + }); + + it('should return element', () => { + expect(renderPreview).toBe(previewElement); + }); + }); + + describe('renderTemplate', () => { + let preview; + let name; + let resource; + let template; + let renderTemplate; + + beforeEach(() => { + preview = { resourceID: 1, image: 'image' }; + name = 'name'; + resource = 'resource'; + template = ` + <div class="panel panel-default"> + <div class="panel-heading">name</div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,image"/> + </div> + </div> + `; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']); + + spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name); + balsamiqViewer.getResource.and.returnValue(resource); + + renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); + }); + + it('should call .getResource', () => { + expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID); + }); + + it('should call .parseTitle', () => { + expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource); + }); + + it('should return the template string', function () { + expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); + }); + }); + + describe('parsePreview', () => { + let preview; + let parsePreview; + + beforeEach(() => { + preview = ['{}', '{ "id": 1 }']; + + spyOn(JSON, 'parse').and.callThrough(); + + parsePreview = BalsamiqViewer.parsePreview(preview); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); + + it('should return the parsed JSON', () => { + expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }')); + }); + }); + + describe('parseTitle', () => { + let title; + let parseTitle; + + beforeEach(() => { + title = { values: [['{}', '{}', '{"name":"name"}']] }; + + spyOn(JSON, 'parse').and.callThrough(); + + parseTitle = BalsamiqViewer.parseTitle(title); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); + + it('should return the name value', () => { + expect(parseTitle).toBe('name'); + }); + }); + + describe('onError', () => { + beforeEach(() => { + spyOn(window, 'Flash'); + + BalsamiqViewer.onError(); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'onError'); + + it('should instantiate Flash', () => { + expect(window.Flash).toHaveBeenCalledWith('Balsamiq file could not be loaded.'); + }); + }); +}); diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index de072e7e470..376e706d1db 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -1,12 +1,12 @@ /* global List */ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global boardsMockInterceptor */ /* global BoardService */ import Vue from 'vue'; -import '~/boards/models/user'; +import '~/boards/models/assignee'; require('~/boards/models/list'); require('~/boards/models/label'); @@ -133,12 +133,12 @@ describe('Issue card', () => { }); it('does not set detail issue if img is clicked', (done) => { - vm.issue.assignee = new ListUser({ + vm.issue.assignees = [new ListAssignee({ id: 1, name: 'testing 123', username: 'test', avatar: 'test_image', - }); + })]; Vue.nextTick(() => { triggerEvent('mouseup', vm.$el.querySelector('img')); diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js index 3f598887603..a89be911667 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/javascripts/boards/board_list_spec.js @@ -35,6 +35,7 @@ describe('Board list component', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); list.issuesSize = 1; list.issues.push(issue); diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js index b55ff2f473a..5ea160b7790 100644 --- a/spec/javascripts/boards/boards_store_spec.js +++ b/spec/javascripts/boards/boards_store_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Store', () => { beforeEach(() => { @@ -212,7 +212,8 @@ describe('Store', () => { title: 'Testing', iid: 2, confidential: false, - labels: [] + labels: [], + assignees: [], }); const list = gl.issueBoards.BoardsStore.addList(listObj); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index 1a5e9e9fd07..fddde799d01 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -1,20 +1,20 @@ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global ListIssue */ import Vue from 'vue'; -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/boards_store'); -require('~/boards/components/issue_card_inner'); -require('./mock_data'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/boards_store'; +import '~/boards/components/issue_card_inner'; +import './mock_data'; describe('Issue card component', () => { - const user = new ListUser({ + const user = new ListAssignee({ id: 1, name: 'testing 123', username: 'test', @@ -40,6 +40,7 @@ describe('Issue card component', () => { iid: 1, confidential: false, labels: [list.label], + assignees: [], }); component = new Vue({ @@ -92,12 +93,12 @@ describe('Issue card component', () => { it('renders confidential icon', (done) => { component.issue.confidential = true; - setTimeout(() => { + Vue.nextTick(() => { expect( component.$el.querySelector('.confidential-icon'), ).not.toBeNull(); done(); - }, 0); + }); }); it('renders issue ID with #', () => { @@ -109,34 +110,32 @@ describe('Issue card component', () => { describe('assignee', () => { it('does not render assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).toBeNull(); }); describe('exists', () => { beforeEach((done) => { - component.issue.assignee = user; + component.issue.assignees = [user]; - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('renders assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).not.toBeNull(); }); it('sets title', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('title'), + component.$el.querySelector('.card-assignee a').getAttribute('title'), ).toContain(`Assigned to ${user.name}`); }); it('sets users path', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('href'), + component.$el.querySelector('.card-assignee a').getAttribute('href'), ).toBe('/test'); }); @@ -146,6 +145,96 @@ describe('Issue card component', () => { ).not.toBeNull(); }); }); + + describe('assignee default avatar', () => { + beforeEach((done) => { + component.issue.assignees = [new ListAssignee({ + id: 1, + name: 'testing 123', + username: 'test', + }, 'default_avatar')]; + + Vue.nextTick(done); + }); + + it('displays defaults avatar if users avatar is null', () => { + expect( + component.$el.querySelector('.card-assignee img'), + ).not.toBeNull(); + expect( + component.$el.querySelector('.card-assignee img').getAttribute('src'), + ).toBe('default_avatar'); + }); + }); + }); + + describe('multiple assignees', () => { + beforeEach((done) => { + component.issue.assignees = [ + user, + new ListAssignee({ + id: 2, + name: 'user2', + username: 'user2', + avatar: 'test_image', + }), + new ListAssignee({ + id: 3, + name: 'user3', + username: 'user3', + avatar: 'test_image', + }), + new ListAssignee({ + id: 4, + name: 'user4', + username: 'user4', + avatar: 'test_image', + })]; + + Vue.nextTick(() => done()); + }); + + it('renders all four assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(4); + }); + + describe('more than four assignees', () => { + beforeEach((done) => { + component.issue.assignees.push(new ListAssignee({ + id: 5, + name: 'user5', + username: 'user5', + avatar: 'test_image', + })); + + Vue.nextTick(() => done()); + }); + + it('renders more avatar counter', () => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('+2'); + }); + + it('renders three assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(3); + }); + + it('renders 99+ avatar counter', (done) => { + for (let i = 5; i < 104; i += 1) { + const u = new ListAssignee({ + id: i, + name: 'name', + username: 'username', + avatar: 'test_image', + }); + component.issue.assignees.push(u); + } + + Vue.nextTick(() => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('99+'); + done(); + }); + }); + }); }); describe('labels', () => { @@ -159,9 +248,7 @@ describe('Issue card component', () => { beforeEach((done) => { component.issue.addLabel(label1); - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('does not render list label', () => { diff --git a/spec/javascripts/boards/issue_spec.js b/spec/javascripts/boards/issue_spec.js index c96dfe94a4a..cd1497bc5e6 100644 --- a/spec/javascripts/boards/issue_spec.js +++ b/spec/javascripts/boards/issue_spec.js @@ -2,14 +2,15 @@ /* global BoardService */ /* global ListIssue */ -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import Vue from 'vue'; +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Issue model', () => { let issue; @@ -27,7 +28,13 @@ describe('Issue model', () => { title: 'test', color: 'red', description: 'testing' - }] + }], + assignees: [{ + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }], }); }); @@ -80,6 +87,33 @@ describe('Issue model', () => { expect(issue.labels.length).toBe(0); }); + it('adds assignee', () => { + issue.addAssignee({ + id: 2, + name: 'Bruce Wayne', + username: 'batman', + avatar_url: 'http://batman', + }); + + expect(issue.assignees.length).toBe(2); + }); + + it('finds assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + expect(assignee).toBeDefined(); + }); + + it('removes assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + issue.removeAssignee(assignee); + expect(issue.assignees.length).toBe(0); + }); + + it('removes all assignees', () => { + issue.removeAllAssignees(); + expect(issue.assignees.length).toBe(0); + }); + it('sets position to infinity if no position is stored', () => { expect(issue.position).toBe(Infinity); }); @@ -90,9 +124,31 @@ describe('Issue model', () => { iid: 1, confidential: false, relative_position: 1, - labels: [] + labels: [], + assignees: [], }); expect(relativePositionIssue.position).toBe(1); }); + + describe('update', () => { + it('passes assignee ids when there are assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([1]); + done(); + }); + + issue.update('url'); + }); + + it('passes assignee ids of [0] when there are no assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([0]); + done(); + }); + + issue.removeAllAssignees(); + issue.update('url'); + }); + }); }); diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js index 24a2da9f6b6..8e3d9fd77a0 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/javascripts/boards/list_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('List model', () => { let list; @@ -94,7 +94,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label, listDup.label] + labels: [list.label, listDup.label], + assignees: [], }); list.issues.push(issue); @@ -119,7 +120,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000) + i, confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); } list.issuesSize = 50; @@ -137,7 +139,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); list.issuesSize = 2; diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index a4fa694eebe..a64c3964ee3 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -33,7 +33,8 @@ const BoardsMockData = { title: 'Testing', iid: 1, confidential: false, - labels: [] + labels: [], + assignees: [], }], size: 1 } diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js index 80db816aff8..32e6d04df9f 100644 --- a/spec/javascripts/boards/modal_store_spec.js +++ b/spec/javascripts/boards/modal_store_spec.js @@ -1,10 +1,10 @@ /* global ListIssue */ -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/modal_store'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/modal_store'; describe('Modal store', () => { let issue; @@ -21,12 +21,14 @@ describe('Modal store', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); issue2 = new ListIssue({ title: 'Testing', iid: 2, confidential: false, labels: [], + assignees: [], }); Store.store.issues.push(issue); Store.store.issues.push(issue2); diff --git a/spec/javascripts/cycle_analytics/limit_warning_component_spec.js b/spec/javascripts/cycle_analytics/limit_warning_component_spec.js index 50000c5a5f5..2fb9eb0ca85 100644 --- a/spec/javascripts/cycle_analytics/limit_warning_component_spec.js +++ b/spec/javascripts/cycle_analytics/limit_warning_component_spec.js @@ -1,6 +1,9 @@ import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; import limitWarningComp from '~/cycle_analytics/components/limit_warning_component'; +Vue.use(Translate); + describe('Limit warning component', () => { let component; let LimitWarningComponent; diff --git a/spec/javascripts/deploy_keys/components/action_btn_spec.js b/spec/javascripts/deploy_keys/components/action_btn_spec.js new file mode 100644 index 00000000000..5b93fbc5575 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/action_btn_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; +import eventHub from '~/deploy_keys/eventhub'; +import actionBtn from '~/deploy_keys/components/action_btn.vue'; + +describe('Deploy keys action btn', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + const deployKey = data.enabled_keys[0]; + let vm; + + beforeEach((done) => { + const ActionBtnComponent = Vue.extend(actionBtn); + + vm = new ActionBtnComponent({ + propsData: { + deployKey, + type: 'enable', + }, + }).$mount(); + + setTimeout(done); + }); + + it('renders the type as uppercase', () => { + expect( + vm.$el.textContent.trim(), + ).toBe('Enable'); + }); + + it('sends eventHub event with btn type', (done) => { + spyOn(eventHub, '$emit'); + + vm.$el.click(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('enable.key', deployKey); + + done(); + }); + }); + + it('shows loading spinner after click', (done) => { + vm.$el.click(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.fa'), + ).toBeDefined(); + + done(); + }); + }); + + it('disables button after click', (done) => { + vm.$el.click(); + + setTimeout(() => { + expect( + vm.$el.classList.contains('disabled'), + ).toBeTruthy(); + + expect( + vm.$el.getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js new file mode 100644 index 00000000000..700897f50b0 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/app_spec.js @@ -0,0 +1,142 @@ +import Vue from 'vue'; +import eventHub from '~/deploy_keys/eventhub'; +import deployKeysApp from '~/deploy_keys/components/app.vue'; + +describe('Deploy keys app component', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + let vm; + + const deployKeysResponse = (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + })); + }; + + beforeEach((done) => { + const Component = Vue.extend(deployKeysApp); + + Vue.http.interceptors.push(deployKeysResponse); + + vm = new Component({ + propsData: { + endpoint: '/test', + }, + }).$mount(); + + setTimeout(done); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse); + }); + + it('renders loading icon', (done) => { + vm.store.keys = {}; + vm.isLoading = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(0); + + expect( + vm.$el.querySelector('.fa-spinner'), + ).toBeDefined(); + + done(); + }); + }); + + it('renders keys panels', () => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(3); + }); + + it('does not render key panels when keys object is empty', (done) => { + vm.store.keys = {}; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(0); + + done(); + }); + }); + + it('does not render public panel when empty', (done) => { + vm.store.keys.public_keys = []; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(2); + + done(); + }); + }); + + it('re-fetches deploy keys when enabling a key', (done) => { + const key = data.public_keys[0]; + + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'enableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); + + eventHub.$emit('enable.key', key); + + expect(vm.service.enableKey).toHaveBeenCalledWith(key.id); + }); + + it('re-fetches deploy keys when disabling a key', (done) => { + const key = data.public_keys[0]; + + spyOn(window, 'confirm').and.returnValue(true); + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); + + eventHub.$emit('disable.key', key); + + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + }); + + it('calls disableKey when removing a key', (done) => { + const key = data.public_keys[0]; + + spyOn(window, 'confirm').and.returnValue(true); + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); + + eventHub.$emit('remove.key', key); + + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + }); + + it('hasKeys returns true when there are keys', () => { + expect(vm.hasKeys).toEqual(3); + }); +}); diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js new file mode 100644 index 00000000000..793ab8c451d --- /dev/null +++ b/spec/javascripts/deploy_keys/components/key_spec.js @@ -0,0 +1,92 @@ +import Vue from 'vue'; +import DeployKeysStore from '~/deploy_keys/store'; +import key from '~/deploy_keys/components/key.vue'; + +describe('Deploy keys key', () => { + let vm; + const KeyComponent = Vue.extend(key); + const data = getJSONFixture('deploy_keys/keys.json'); + const createComponent = (deployKey) => { + const store = new DeployKeysStore(); + store.keys = data; + + vm = new KeyComponent({ + propsData: { + deployKey, + store, + }, + }).$mount(); + }; + + describe('enabled key', () => { + const deployKey = data.enabled_keys[0]; + + beforeEach((done) => { + createComponent(deployKey); + + setTimeout(done); + }); + + it('renders the keys title', () => { + expect( + vm.$el.querySelector('.title').textContent.trim(), + ).toContain('My title'); + }); + + it('renders human friendly formatted created date', () => { + expect( + vm.$el.querySelector('.key-created-at').textContent.trim(), + ).toBe(`created ${gl.utils.getTimeago().format(deployKey.created_at)}`); + }); + + it('shows remove button', () => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Remove'); + }); + + it('shows write access text when key has write access', (done) => { + vm.deployKey.can_push = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.write-access-allowed'), + ).not.toBeNull(); + + expect( + vm.$el.querySelector('.write-access-allowed').textContent.trim(), + ).toBe('Write access allowed'); + + done(); + }); + }); + }); + + describe('public keys', () => { + const deployKey = data.public_keys[0]; + + beforeEach((done) => { + createComponent(deployKey); + + setTimeout(done); + }); + + it('shows enable button', () => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Enable'); + }); + + it('shows disable button when key is enabled', (done) => { + vm.store.keys.enabled_keys.push(deployKey); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Disable'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/deploy_keys/components/keys_panel_spec.js b/spec/javascripts/deploy_keys/components/keys_panel_spec.js new file mode 100644 index 00000000000..a69b39c35c4 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/keys_panel_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; +import DeployKeysStore from '~/deploy_keys/store'; +import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue'; + +describe('Deploy keys panel', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + let vm; + + beforeEach((done) => { + const DeployKeysPanelComponent = Vue.extend(deployKeysPanel); + const store = new DeployKeysStore(); + store.keys = data; + + vm = new DeployKeysPanelComponent({ + propsData: { + title: 'test', + keys: data.enabled_keys, + showHelpBox: true, + store, + }, + }).$mount(); + + setTimeout(done); + }); + + it('renders the title with keys count', () => { + expect( + vm.$el.querySelector('h5').textContent.trim(), + ).toContain('test'); + + expect( + vm.$el.querySelector('h5').textContent.trim(), + ).toContain(`(${vm.keys.length})`); + }); + + it('renders list of keys', () => { + expect( + vm.$el.querySelectorAll('li').length, + ).toBe(vm.keys.length); + }); + + it('renders help box if keys are empty', (done) => { + vm.keys = []; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.settings-message'), + ).toBeDefined(); + + expect( + vm.$el.querySelector('.settings-message').textContent.trim(), + ).toBe('No deploy keys found. Create one with the form above.'); + + done(); + }); + }); + + it('does not render help box if keys are empty & showHelpBox is false', (done) => { + vm.keys = []; + vm.showHelpBox = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.settings-message'), + ).toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js index fd153a49fcd..b9d28db74cc 100644 --- a/spec/javascripts/droplab/constants_spec.js +++ b/spec/javascripts/droplab/constants_spec.js @@ -27,6 +27,12 @@ describe('constants', function () { }); }); + describe('TEMPLATE_REGEX', function () { + it('should be a handlebars templating syntax regex', function() { + expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g); + }); + }); + describe('IGNORE_CLASS', function () { it('should be `droplab-item-ignore`', function() { expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore'); diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index 7516b301917..e7786e8cc2c 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -451,7 +451,7 @@ describe('DropDown', function () { this.html = 'html'; this.template = { firstChild: { outerHTML: 'outerHTML', style: {} } }; - spyOn(utils, 't').and.returnValue(this.html); + spyOn(utils, 'template').and.returnValue(this.html); spyOn(document, 'createElement').and.returnValue(this.template); spyOn(this.dropdown, 'setImagesSrc'); @@ -459,7 +459,7 @@ describe('DropDown', function () { }); it('should call utils.t with .templateString and data', function () { - expect(utils.t).toHaveBeenCalledWith(this.templateString, this.data); + expect(utils.template).toHaveBeenCalledWith(this.templateString, this.data); }); it('should call document.createElement', function () { diff --git a/spec/javascripts/environments/environment_actions_spec.js b/spec/javascripts/environments/environment_actions_spec.js index 676bf61cfd9..596d812c724 100644 --- a/spec/javascripts/environments/environment_actions_spec.js +++ b/spec/javascripts/environments/environment_actions_spec.js @@ -4,7 +4,6 @@ import actionsComp from '~/environments/components/environment_actions.vue'; describe('Actions Component', () => { let ActionsComponent; let actionsMock; - let spy; let component; beforeEach(() => { @@ -26,13 +25,9 @@ describe('Actions Component', () => { }, ]; - spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); component = new ActionsComponent({ propsData: { actions: actionsMock, - service: { - postAction: spy, - }, }, }).$mount(); }); @@ -48,13 +43,6 @@ describe('Actions Component', () => { ).toEqual(actionsMock.length); }); - it('should call the service when an action is clicked', () => { - component.$el.querySelector('.dropdown').click(); - component.$el.querySelector('.js-manual-action-link').click(); - - expect(spy).toHaveBeenCalledWith(actionsMock[0].play_path); - }); - it('should render a disabled action when it\'s not playable', () => { expect( component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'), diff --git a/spec/javascripts/environments/environment_rollback_spec.js b/spec/javascripts/environments/environment_rollback_spec.js index 25397714a76..eb8e49d81fe 100644 --- a/spec/javascripts/environments/environment_rollback_spec.js +++ b/spec/javascripts/environments/environment_rollback_spec.js @@ -4,11 +4,9 @@ import rollbackComp from '~/environments/components/environment_rollback.vue'; describe('Rollback Component', () => { const retryURL = 'https://gitlab.com/retry'; let RollbackComponent; - let spy; beforeEach(() => { RollbackComponent = Vue.extend(rollbackComp); - spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); }); it('Should render Re-deploy label when isLastDeployment is true', () => { @@ -17,9 +15,6 @@ describe('Rollback Component', () => { propsData: { retryUrl: retryURL, isLastDeployment: true, - service: { - postAction: spy, - }, }, }).$mount(); @@ -32,28 +27,9 @@ describe('Rollback Component', () => { propsData: { retryUrl: retryURL, isLastDeployment: false, - service: { - postAction: spy, - }, }, }).$mount(); expect(component.$el.querySelector('span').textContent).toContain('Rollback'); }); - - it('should call the service when the button is clicked', () => { - const component = new RollbackComponent({ - propsData: { - retryUrl: retryURL, - isLastDeployment: false, - service: { - postAction: spy, - }, - }, - }).$mount(); - - component.$el.click(); - - expect(spy).toHaveBeenCalledWith(retryURL); - }); }); diff --git a/spec/javascripts/environments/environment_stop_spec.js b/spec/javascripts/environments/environment_stop_spec.js index 942e4aaabd4..8131f1e5b11 100644 --- a/spec/javascripts/environments/environment_stop_spec.js +++ b/spec/javascripts/environments/environment_stop_spec.js @@ -4,20 +4,15 @@ import stopComp from '~/environments/components/environment_stop.vue'; describe('Stop Component', () => { let StopComponent; let component; - let spy; const stopURL = '/stop'; beforeEach(() => { StopComponent = Vue.extend(stopComp); - spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); spyOn(window, 'confirm').and.returnValue(true); component = new StopComponent({ propsData: { stopUrl: stopURL, - service: { - postAction: spy, - }, }, }).$mount(); }); @@ -26,9 +21,4 @@ describe('Stop Component', () => { expect(component.$el.tagName).toEqual('BUTTON'); expect(component.$el.getAttribute('title')).toEqual('Stop'); }); - - it('should call the service when an action is clicked', () => { - component.$el.click(); - expect(spy).toHaveBeenCalled(); - }); }); diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js index 2722882375f..d0f09a561d5 100644 --- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -76,6 +76,26 @@ describe('RecentSearchesDropdownContent', () => { }); }); + describe('if isLocalStorageAvailable is `false`', () => { + let el; + + beforeEach(() => { + const props = Object.assign({ isLocalStorageAvailable: false }, propsDataWithItems); + + vm = createComponent(props); + el = vm.$el; + }); + + it('should render an info note', () => { + const note = el.querySelector('.dropdown-info-note'); + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + + expect(note).toBeDefined(); + expect(note.innerText.trim()).toBe('This feature requires local storage to be enabled'); + expect(items.length).toEqual(propsDataWithoutItems.items.length); + }); + }); + describe('computed', () => { describe('processedItems', () => { it('with items', () => { diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index e747aa497c2..063d547d00c 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -1,3 +1,7 @@ +import * as recentSearchesStoreSrc from '~/filtered_search/stores/recent_searches_store'; +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; + require('~/lib/utils/url_utility'); require('~/lib/utils/common_utils'); require('~/filtered_search/filtered_search_token_keys'); @@ -60,6 +64,36 @@ describe('Filtered Search Manager', () => { manager.cleanup(); }); + describe('class constructor', () => { + const isLocalStorageAvailable = 'isLocalStorageAvailable'; + let filteredSearchManager; + + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable); + spyOn(recentSearchesStoreSrc, 'default'); + + filteredSearchManager = new gl.FilteredSearchManager(); + + return filteredSearchManager; + }); + + it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => { + expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); + expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({ + isLocalStorageAvailable, + }); + }); + + it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { + spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() => Promise.reject(new RecentSearchesServiceError())); + spyOn(window, 'Flash'); + + filteredSearchManager = new gl.FilteredSearchManager(); + + expect(window.Flash).not.toHaveBeenCalled(); + }); + }); + describe('search', () => { const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index d75b9061281..8b750561eb7 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + require('~/filtered_search/filtered_search_visual_tokens'); const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper'); @@ -611,4 +613,103 @@ describe('Filtered Search Visual Tokens', () => { expect(token.querySelector('.value').innerText).toEqual('~bug'); }); }); + + describe('renderVisualTokenValue', () => { + let searchTokens; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + `); + + searchTokens = document.querySelectorAll('.filtered-search-token'); + }); + + it('renders a token value element', () => { + spyOn(gl.FilteredSearchVisualTokens, 'updateLabelTokenColor'); + const updateLabelTokenColorSpy = gl.FilteredSearchVisualTokens.updateLabelTokenColor; + + expect(searchTokens.length).toBe(2); + Array.prototype.forEach.call(searchTokens, (token) => { + updateLabelTokenColorSpy.calls.reset(); + + const tokenName = token.querySelector('.name').innerText; + const tokenValue = 'new value'; + gl.FilteredSearchVisualTokens.renderVisualTokenValue(token, tokenName, tokenValue); + + const tokenValueElement = token.querySelector('.value'); + expect(tokenValueElement.innerText).toBe(tokenValue); + + if (tokenName.toLowerCase() === 'label') { + const tokenValueContainer = token.querySelector('.value-container'); + expect(updateLabelTokenColorSpy.calls.count()).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValue]; + expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs); + } else { + expect(updateLabelTokenColorSpy.calls.count()).toBe(0); + } + }); + }); + }); + + describe('updateLabelTokenColor', () => { + const jsonFixtureName = 'labels/project_labels.json'; + const dummyEndpoint = '/dummy/endpoint'; + + preloadFixtures(jsonFixtureName); + const labelData = getJSONFixture(jsonFixtureName); + const findLabel = tokenValue => labelData.find( + label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`, + ); + + const bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~doesnotexist'); + const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~"some space"'); + + const parseColor = (color) => { + const dummyElement = document.createElement('div'); + dummyElement.style.color = color; + return dummyElement.style.color; + }; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${bugLabelToken.outerHTML} + ${missingLabelToken.outerHTML} + ${spaceLabelToken.outerHTML} + `); + + const filteredSearchInput = document.querySelector('.filtered-search'); + filteredSearchInput.dataset.baseEndpoint = dummyEndpoint; + + AjaxCache.internalStorage = { }; + AjaxCache.internalStorage[`${dummyEndpoint}/labels.json`] = labelData; + }); + + const testCase = (token, done) => { + const tokenValueContainer = token.querySelector('.value-container'); + const tokenValue = token.querySelector('.value').innerText; + const label = findLabel(tokenValue); + + gl.FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + if (label) { + expect(tokenValueContainer.getAttribute('style')).not.toBe(null); + expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); + expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); + } else { + expect(token).toBe(missingLabelToken); + expect(tokenValueContainer.getAttribute('style')).toBe(null); + } + }) + .then(done) + .catch(fail); + }; + + it('updates the color of a label token', done => testCase(bugLabelToken, done)); + it('updates the color of a label token with spaces', done => testCase(spaceLabelToken, done)); + it('does not change color of a missing label', done => testCase(missingLabelToken, done)); + }); }); diff --git a/spec/javascripts/filtered_search/recent_searches_root_spec.js b/spec/javascripts/filtered_search/recent_searches_root_spec.js new file mode 100644 index 00000000000..d8ba6de5f45 --- /dev/null +++ b/spec/javascripts/filtered_search/recent_searches_root_spec.js @@ -0,0 +1,31 @@ +import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; +import * as vueSrc from 'vue'; + +describe('RecentSearchesRoot', () => { + describe('render', () => { + let recentSearchesRoot; + let data; + let template; + + beforeEach(() => { + recentSearchesRoot = { + store: { + state: 'state', + }, + }; + + spyOn(vueSrc, 'default').and.callFake((options) => { + data = options.data; + template = options.template; + }); + + RecentSearchesRoot.prototype.render.call(recentSearchesRoot); + }); + + it('should instantiate Vue', () => { + expect(vueSrc.default).toHaveBeenCalled(); + expect(data()).toBe(recentSearchesRoot.store.state); + expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"'); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js new file mode 100644 index 00000000000..ea7c146fa4f --- /dev/null +++ b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js @@ -0,0 +1,18 @@ +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; + +describe('RecentSearchesServiceError', () => { + let recentSearchesServiceError; + + beforeEach(() => { + recentSearchesServiceError = new RecentSearchesServiceError(); + }); + + it('instantiates an instance of RecentSearchesServiceError and not an Error', () => { + expect(recentSearchesServiceError).toEqual(jasmine.any(RecentSearchesServiceError)); + expect(recentSearchesServiceError.name).toBe('RecentSearchesServiceError'); + }); + + it('should set a default message', () => { + expect(recentSearchesServiceError.message).toBe('Recent Searches Service is unavailable'); + }); +}); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js index c255bf7c939..31fa478804a 100644 --- a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js +++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js @@ -1,6 +1,7 @@ /* eslint-disable promise/catch-or-return */ import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import AccessorUtilities from '~/lib/utils/accessor'; describe('RecentSearchesService', () => { let service; @@ -11,6 +12,10 @@ describe('RecentSearchesService', () => { }); describe('fetch', () => { + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true); + }); + it('should default to empty array', (done) => { const fetchItemsPromise = service.fetch(); @@ -29,11 +34,21 @@ describe('RecentSearchesService', () => { const fetchItemsPromise = service.fetch(); fetchItemsPromise - .catch(() => { + .catch((error) => { + expect(error).toEqual(jasmine.any(SyntaxError)); done(); }); }); + it('should reject when service is unavailable', (done) => { + RecentSearchesService.isAvailable.and.returnValue(false); + + service.fetch().catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + done(); + }); + }); + it('should return items from localStorage', (done) => { window.localStorage.setItem(service.localStorageKey, '["foo", "bar"]'); const fetchItemsPromise = service.fetch(); @@ -44,15 +59,89 @@ describe('RecentSearchesService', () => { done(); }); }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(false); + + spyOn(window.localStorage, 'getItem'); + + RecentSearchesService.prototype.fetch(); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); }); describe('setRecentSearches', () => { + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true); + }); + it('should save things in localStorage', () => { const items = ['foo', 'bar']; service.save(items); - const newLocalStorageValue = - window.localStorage.getItem(service.localStorageKey); + const newLocalStorageValue = window.localStorage.getItem(service.localStorageKey); expect(JSON.parse(newLocalStorageValue)).toEqual(items); }); }); + + describe('save', () => { + beforeEach(() => { + spyOn(window.localStorage, 'setItem'); + spyOn(RecentSearchesService, 'isAvailable'); + }); + + describe('if .isAvailable returns `true`', () => { + const searchesString = 'searchesString'; + const localStorageKey = 'localStorageKey'; + const recentSearchesService = { + localStorageKey, + }; + + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(true); + + spyOn(JSON, 'stringify').and.returnValue(searchesString); + + RecentSearchesService.prototype.save.call(recentSearchesService); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString); + }); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(false); + + RecentSearchesService.prototype.save(); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); + + describe('isAvailable', () => { + let isAvailable; + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.callThrough(); + + isAvailable = RecentSearchesService.isAvailable(); + }); + + it('should call .isLocalStorageAccessSafe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + }); + + it('should return a boolean', () => { + expect(typeof isAvailable).toBe('boolean'); + }); + }); }); diff --git a/spec/javascripts/fixtures/deploy_keys.rb b/spec/javascripts/fixtures/deploy_keys.rb new file mode 100644 index 00000000000..16e598a4b29 --- /dev/null +++ b/spec/javascripts/fixtures/deploy_keys.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') } + let(:project2) { create(:empty_project, :internal)} + + before(:all) do + clean_frontend_fixtures('deploy_keys/') + end + + before(:each) do + sign_in(admin) + end + + render_views + + it 'deploy_keys/keys.json' do |example| + create(:deploy_key, public: true) + project_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com') + internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') + create(:deploy_keys_project, project: project, deploy_key: project_key) + create(:deploy_keys_project, project: project2, deploy_key: internal_key) + + get :index, + namespace_id: project.namespace.to_param, + project_id: project, + format: :json + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/fixtures/environments.rb b/spec/javascripts/fixtures/environments.rb new file mode 100644 index 00000000000..3474f4696ef --- /dev/null +++ b/spec/javascripts/fixtures/environments.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe Projects::EnvironmentsController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'environments-project') } + let(:environment) { create(:environment, name: 'production', project: project) } + + render_views + + before(:all) do + clean_frontend_fixtures('environments/metrics') + end + + before(:each) do + sign_in(admin) + end + + it 'environments/metrics/metrics.html.raw' do |example| + get :metrics, + namespace_id: project.namespace, + project_id: project, + id: environment.id + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/fixtures/environments/metrics.html.haml b/spec/javascripts/fixtures/environments/metrics.html.haml deleted file mode 100644 index e2dd9519898..00000000000 --- a/spec/javascripts/fixtures/environments/metrics.html.haml +++ /dev/null @@ -1,62 +0,0 @@ -.prometheus-container{ 'data-has-metrics': "false", 'data-doc-link': '/help/administration/monitoring/prometheus/index.md', 'data-prometheus-integration': '/root/hello-prometheus/services/prometheus/edit' } - .top-area - .row - .col-sm-6 - %h3.page-title - Metrics for environment - .prometheus-state - .js-getting-started.hidden - .row - .col-md-4.col-md-offset-4.state-svg - %svg - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Get started with performance monitoring - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments. Learn more about performance monitoring - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - %a.btn.btn-success - Configure Prometheus - .js-loading.hidden - .row - .col-md-4.col-md-offset-4.state-svg - %svg - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Waiting for performance data - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available. - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - %a.btn.btn-success - View documentation - .js-unable-to-connect.hidden - .row - .col-md-4.col-md-offset-4.state-svg - %svg - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Unable to connect to Prometheus server - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Ensure connectivity is available from the GitLab server to the Prometheus server - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - %a.btn.btn-success - View documentation - .prometheus-graphs - .row - .col-sm-12 - %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } - .row - .col-sm-12 - %svg.prometheus-graph{ 'graph-type' => 'memory_values' } diff --git a/spec/javascripts/fixtures/labels.rb b/spec/javascripts/fixtures/labels.rb new file mode 100644 index 00000000000..2e4811b64a4 --- /dev/null +++ b/spec/javascripts/fixtures/labels.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'Labels (JavaScript fixtures)' do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:group) { create(:group, name: 'frontend-fixtures-group' )} + let(:project) { create(:project_empty_repo, namespace: group, path: 'labels-project') } + + let!(:project_label_bug) { create(:label, project: project, title: 'bug', color: '#FF0000') } + let!(:project_label_enhancement) { create(:label, project: project, title: 'enhancement', color: '#00FF00') } + let!(:project_label_feature) { create(:label, project: project, title: 'feature', color: '#0000FF') } + + let!(:group_label_roses) { create(:group_label, group: group, title: 'roses', color: '#FF0000') } + let!(:groub_label_space) { create(:group_label, group: group, title: 'some space', color: '#FFFFFF') } + let!(:groub_label_violets) { create(:group_label, group: group, title: 'violets', color: '#0000FF') } + + before(:all) do + clean_frontend_fixtures('labels/') + end + + describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do + render_views + + before(:each) do + sign_in(admin) + end + + it 'labels/group_labels.json' do |example| + get :index, + group_id: group, + format: 'json' + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end + + describe Projects::LabelsController, '(JavaScript fixtures)', type: :controller do + render_views + + before(:each) do + sign_in(admin) + end + + it 'labels/project_labels.json' do |example| + get :index, + namespace_id: group, + project_id: project, + format: 'json' + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end +end diff --git a/spec/javascripts/fixtures/mini_dropdown_graph.html.haml b/spec/javascripts/fixtures/mini_dropdown_graph.html.haml index 29370b974af..b532b48a95b 100644 --- a/spec/javascripts/fixtures/mini_dropdown_graph.html.haml +++ b/spec/javascripts/fixtures/mini_dropdown_graph.html.haml @@ -3,7 +3,7 @@ Dropdown %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container - .js-builds-dropdown-list.scrollable-menu + %li.js-builds-dropdown-list.scrollable-menu - .js-builds-dropdown-loading.builds-dropdown-loading.hidden - %span.fa.fa-spinner.fa-spin + %li.js-builds-dropdown-loading.hidden + %span.fa.fa-spinner diff --git a/spec/javascripts/helpers/user_mock_data_helper.js b/spec/javascripts/helpers/user_mock_data_helper.js new file mode 100644 index 00000000000..a9783ea065c --- /dev/null +++ b/spec/javascripts/helpers/user_mock_data_helper.js @@ -0,0 +1,16 @@ +export default { + createNumberRandomUsers(numberUsers) { + const users = []; + for (let i = 0; i < numberUsers; i = i += 1) { + users.push( + { + avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: (i + 1), + name: `GitLab User ${i}`, + username: `gitlab${i}`, + }, + ); + } + return users; + }, +}; diff --git a/spec/javascripts/issuable_time_tracker_spec.js b/spec/javascripts/issuable_time_tracker_spec.js index 0a830f25e29..8ff93c4f918 100644 --- a/spec/javascripts/issuable_time_tracker_spec.js +++ b/spec/javascripts/issuable_time_tracker_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; -require('~/issuable/time_tracking/components/time_tracker'); +import timeTracker from '~/sidebar/components/time_tracking/time_tracker'; function initTimeTrackingComponent(opts) { setFixtures(` @@ -16,187 +16,185 @@ function initTimeTrackingComponent(opts) { time_spent: opts.timeSpent, human_time_estimate: opts.timeEstimateHumanReadable, human_time_spent: opts.timeSpentHumanReadable, - docsUrl: '/help/workflow/time_tracking.md', + rootPath: '/', }; - const TimeTrackingComponent = Vue.component('issuable-time-tracker'); + const TimeTrackingComponent = Vue.extend(timeTracker); this.timeTracker = new TimeTrackingComponent({ el: '#mock-container', propsData: this.initialData, }); } -((gl) => { - describe('Issuable Time Tracker', function() { - describe('Initialization', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); +describe('Issuable Time Tracker', function() { + describe('Initialization', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should return something defined', function() { - expect(this.timeTracker).toBeDefined(); - }); + it('should return something defined', function() { + expect(this.timeTracker).toBeDefined(); + }); - it ('should correctly set timeEstimate', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); - done(); - }); + it ('should correctly set timeEstimate', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); + done(); }); - it ('should correctly set time_spent', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); - done(); - }); + }); + it ('should correctly set time_spent', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); + done(); }); }); + }); - describe('Content Display', function() { - describe('Panes', function() { - describe('Comparison pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + describe('Content Display', function() { + describe('Panes', function() { + describe('Comparison pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); + + it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + Vue.nextTick(() => { + const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); + expect(this.timeTracker.showComparisonState).toBe(true); + done(); }); + }); - it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + describe('Remaining meter', function() { + it('should display the remaining meter with the correct width', function(done) { Vue.nextTick(() => { - const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); - expect(this.timeTracker.showComparisonState).toBe(true); + const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; + const correctWidth = '5%'; + + expect(meterWidth).toBe(correctWidth); done(); - }); + }) }); - describe('Remaining meter', function() { - it('should display the remaining meter with the correct width', function(done) { - Vue.nextTick(() => { - const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; - const correctWidth = '5%'; - - expect(meterWidth).toBe(correctWidth); - done(); - }) - }); - - it('should display the remaining meter with the correct background color when within estimate', function(done) { - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done() - }); + it('should display the remaining meter with the correct background color when within estimate', function(done) { + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done() }); + }); - it('should display the remaining meter with the correct background color when over estimate', function(done) { - this.timeTracker.time_estimate = 100000; - this.timeTracker.time_spent = 20000000; - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done(); - }); + it('should display the remaining meter with the correct background color when over estimate', function(done) { + this.timeTracker.time_estimate = 100000; + this.timeTracker.time_spent = 20000000; + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done(); }); }); }); + }); - describe("Estimate only pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); - }); + describe("Estimate only pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); + }); - it('should display the human readable version of time estimated', function(done) { - Vue.nextTick(() => { - const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; - const correctText = 'Estimated: 2h 46m'; + it('should display the human readable version of time estimated', function(done) { + Vue.nextTick(() => { + const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; + const correctText = 'Estimated: 2h 46m'; - expect(estimateText).toBe(correctText); - done(); - }); + expect(estimateText).toBe(correctText); + done(); }); }); + }); - describe('Spent only pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); + describe('Spent only pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should display the human readable version of time spent', function(done) { - Vue.nextTick(() => { - const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; - const correctText = 'Spent: 1h 23m'; + it('should display the human readable version of time spent', function(done) { + Vue.nextTick(() => { + const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; + const correctText = 'Spent: 1h 23m'; - expect(spentText).toBe(correctText); - done(); - }); + expect(spentText).toBe(correctText); + done(); }); }); + }); - describe('No time tracking pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 }); - }); + describe('No time tracking pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); - it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { - Vue.nextTick(() => { - const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); - const noTrackingText =$noTrackingPane.innerText; - const correctText = 'No estimate or time spent'; + it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { + Vue.nextTick(() => { + const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); + const noTrackingText =$noTrackingPane.innerText; + const correctText = 'No estimate or time spent'; - expect(this.timeTracker.showNoTimeTrackingState).toBe(true); - expect($noTrackingPane).toBeVisible(); - expect(noTrackingText).toBe(correctText); - done(); - }); + expect(this.timeTracker.showNoTimeTrackingState).toBe(true); + expect($noTrackingPane).toBeVisible(); + expect(noTrackingText).toBe(correctText); + done(); }); }); + }); - describe("Help pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); - }); + describe("Help pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); + }); - it('should not show the "Help" pane by default', function(done) { - Vue.nextTick(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + it('should not show the "Help" pane by default', function(done) { + Vue.nextTick(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); - done(); - }); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); + done(); }); + }); - it('should show the "Help" pane when help button is clicked', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should show the "Help" pane when help button is clicked', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(true); - expect($helpPane).toBeVisible(); - done(); - }, 10); - }); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + expect(this.timeTracker.showHelpState).toBe(true); + expect($helpPane).toBeVisible(); + done(); + }, 10); }); + }); - it('should not show the "Help" pane when help button is clicked and then closed', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should not show the "Help" pane when help button is clicked and then closed', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { + setTimeout(() => { - $(this.timeTracker.$el).find('.close-help-button').click(); + $(this.timeTracker.$el).find('.close-help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); - done(); - }, 1000); + done(); }, 1000); - }); + }, 1000); }); }); }); }); }); -})(window.gl || (window.gl = {})); +}); diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js new file mode 100644 index 00000000000..1ec4fe58b08 --- /dev/null +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -0,0 +1,60 @@ +import Vue from 'vue'; +import $ from 'jquery'; +import '~/render_math'; +import '~/render_gfm'; +import issueTitleDescription from '~/issue_show/issue_title_description.vue'; +import issueShowData from './mock_data'; + +window.$ = $; + +const issueShowInterceptor = data => (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + headers: { + 'POLL-INTERVAL': 1, + }, + })); +}; + +describe('Issue Title', () => { + document.body.innerHTML = '<span id="task_status"></span>'; + + let IssueTitleDescriptionComponent; + + beforeEach(() => { + IssueTitleDescriptionComponent = Vue.extend(issueTitleDescription); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); + }); + + it('should render a title/description and update title/description on update', (done) => { + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + + const issueShowComponent = new IssueTitleDescriptionComponent({ + propsData: { + canUpdateIssue: '.css-stuff', + endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', + }, + }).$mount(); + + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('this is a description'); + + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); + + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/issue_title_spec.js b/spec/javascripts/issue_show/issue_title_spec.js deleted file mode 100644 index 03edbf9f947..00000000000 --- a/spec/javascripts/issue_show/issue_title_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import issueTitle from '~/issue_show/issue_title.vue'; - -describe('Issue Title', () => { - let IssueTitleComponent; - - beforeEach(() => { - IssueTitleComponent = Vue.extend(issueTitle); - }); - - it('should render a title', () => { - const component = new IssueTitleComponent({ - propsData: { - initialTitle: 'wow', - endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', - }, - }).$mount(); - - expect(component.$el.classList).toContain('title'); - expect(component.$el.innerHTML).toContain('wow'); - }); -}); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js new file mode 100644 index 00000000000..ad5a7b63470 --- /dev/null +++ b/spec/javascripts/issue_show/mock_data.js @@ -0,0 +1,26 @@ +export default { + initialRequest: { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<p>this is a description!</p>', + description_text: 'this is a description', + issue_number: 1, + task_status: '2 of 4 completed', + }, + secondRequest: { + title: '<p>2</p>', + title_text: '2', + description: '<p>42</p>', + description_text: '42', + issue_number: 1, + task_status: '0 of 0 completed', + }, + issueSpecRequest: { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', + description_text: '- [ ] Task List Item', + issue_number: 1, + task_status: '0 of 1 completed', + }, +}; diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 9a2570ef7e9..763f5ee9e50 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -81,12 +81,6 @@ describe('Issue', function() { this.issue = new Issue(); }); - it('modifies the Markdown field', function() { - spyOn(jQuery, 'ajax').and.stub(); - $('input[type=checkbox]').attr('checked', true).trigger('change'); - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); - }); - it('submits an ajax request on tasklist:changed', function() { spyOn(jQuery, 'ajax').and.callFake(function(req) { expect(req.type).toBe('PATCH'); @@ -108,8 +102,8 @@ describe('Issue', function() { expect(this.$triggeredButton).toHaveProp('disabled', true); expectNewBranchButtonState(true, false); return this.issueStateDeferred; - } else if (req.url === Issue.$btnNewBranch.data('path')) { - expect(req.type).toBe('get'); + } else if (req.url === Issue.createMrDropdownWrap.dataset.canCreatePath) { + expect(req.type).toBe('GET'); expectNewBranchButtonState(true, false); return this.canCreateBranchDeferred; } diff --git a/spec/javascripts/lib/utils/accessor_spec.js b/spec/javascripts/lib/utils/accessor_spec.js new file mode 100644 index 00000000000..b768d6f2a68 --- /dev/null +++ b/spec/javascripts/lib/utils/accessor_spec.js @@ -0,0 +1,78 @@ +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('AccessorUtilities', () => { + const testError = new Error('test error'); + + describe('isPropertyAccessSafe', () => { + let base; + + it('should return `true` if access is safe', () => { + base = { testProp: 'testProp' }; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(true); + }); + + it('should return `false` if access throws an error', () => { + base = { get testProp() { throw testError; } }; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); + }); + + it('should return `false` if property is undefined', () => { + base = {}; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); + }); + }); + + describe('isFunctionCallSafe', () => { + const base = {}; + + it('should return `true` if calling is safe', () => { + base.func = () => {}; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(true); + }); + + it('should return `false` if calling throws an error', () => { + base.func = () => { throw new Error('test error'); }; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); + }); + + it('should return `false` if function is undefined', () => { + base.func = undefined; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); + }); + }); + + describe('isLocalStorageAccessSafe', () => { + beforeEach(() => { + spyOn(window.localStorage, 'setItem'); + spyOn(window.localStorage, 'removeItem'); + }); + + it('should return `true` if access is safe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(true); + }); + + it('should return `false` if access to .setItem isnt safe', () => { + window.localStorage.setItem.and.callFake(() => { throw testError; }); + + expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(false); + }); + + it('should set a test item if access is safe', () => { + AccessorUtilities.isLocalStorageAccessSafe(); + + expect(window.localStorage.setItem).toHaveBeenCalledWith('isLocalStorageAccessSafe', 'true'); + }); + + it('should remove the test item if access is safe', () => { + AccessorUtilities.isLocalStorageAccessSafe(); + + expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLocalStorageAccessSafe'); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js new file mode 100644 index 00000000000..7b466a11b92 --- /dev/null +++ b/spec/javascripts/lib/utils/ajax_cache_spec.js @@ -0,0 +1,129 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + +describe('AjaxCache', () => { + const dummyEndpoint = '/AjaxCache/dummyEndpoint'; + const dummyResponse = { + important: 'dummy data', + }; + let ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + + beforeEach(() => { + AjaxCache.internalStorage = { }; + spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url)); + }); + + describe('#get', () => { + it('returns undefined if cache is empty', () => { + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(undefined); + }); + + it('returns undefined if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(undefined); + }); + + it('returns matching data', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(dummyResponse); + }); + }); + + describe('#hasData', () => { + it('returns false if cache is empty', () => { + expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); + }); + + it('returns false if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); + }); + + it('returns true if data is available', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + expect(AjaxCache.hasData(dummyEndpoint)).toBe(true); + }); + }); + + describe('#purge', () => { + it('does nothing if cache is empty', () => { + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage).toEqual({ }); + }); + + it('does nothing if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage['not matching']).toBe(dummyResponse); + }); + + it('removes matching data', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage).toEqual({ }); + }); + }); + + describe('#retrieve', () => { + it('stores and returns data from Ajax call if cache is empty', (done) => { + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(dummyResponse); + expect(AjaxCache.internalStorage[dummyEndpoint]).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', (done) => { + const dummyStatusText = 'exploded'; + const dummyErrorMessage = 'server exploded'; + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.reject(null, dummyStatusText, dummyErrorMessage); + return deferred.promise(); + }; + + AjaxCache.retrieve(dummyEndpoint) + .then(data => fail(`Received unexpected data: ${JSON.stringify(data)}`)) + .catch((error) => { + expect(error.message).toBe(`${dummyEndpoint}: ${dummyErrorMessage}`); + expect(error.textStatus).toBe(dummyStatusText); + done(); + }) + .catch(fail); + }); + + it('makes no Ajax call if matching data exists', (done) => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + ajaxSpy = () => fail(new Error('expected no Ajax call!')); + + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index a00efa10119..5eb147ed888 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -362,5 +362,16 @@ require('~/lib/utils/common_utils'); gl.utils.setCiStatusFavicon(BUILD_URL); }); }); + + describe('gl.utils.ajaxPost', () => { + it('should perform `$.ajax` call and do `POST` request', () => { + const requestURL = '/some/random/api'; + const data = { keyname: 'value' }; + const ajaxSpy = spyOn($, 'ajax').and.callFake(() => {}); + + gl.utils.ajaxPost(requestURL, data); + expect(ajaxSpy.calls.allArgs()[0][0].type).toEqual('POST'); + }); + }); }); })(); diff --git a/spec/javascripts/monitoring/deployments_spec.js b/spec/javascripts/monitoring/deployments_spec.js new file mode 100644 index 00000000000..19bc11d0f24 --- /dev/null +++ b/spec/javascripts/monitoring/deployments_spec.js @@ -0,0 +1,133 @@ +import d3 from 'd3'; +import PrometheusGraph from '~/monitoring/prometheus_graph'; +import Deployments from '~/monitoring/deployments'; +import { prometheusMockData } from './prometheus_mock_data'; + +describe('Metrics deployments', () => { + const fixtureName = 'environments/metrics/metrics.html.raw'; + let deployment; + let prometheusGraph; + + const graphElement = () => document.querySelector('.prometheus-graph'); + + preloadFixtures(fixtureName); + + beforeEach((done) => { + // Setup the view + loadFixtures(fixtureName); + + d3.selectAll('.prometheus-graph') + .append('g') + .attr('class', 'graph-container'); + + prometheusGraph = new PrometheusGraph(); + deployment = new Deployments(1000, 500); + + spyOn(prometheusGraph, 'init'); + spyOn($, 'ajax').and.callFake(() => { + const d = $.Deferred(); + d.resolve({ + deployments: [{ + id: 1, + created_at: deployment.chartData[10].time, + sha: 'testing', + tag: false, + ref: { + name: 'testing', + }, + }, { + id: 2, + created_at: deployment.chartData[15].time, + sha: '', + tag: true, + ref: { + name: 'tag', + }, + }], + }); + + setTimeout(done); + + return d.promise(); + }); + + prometheusGraph.configureGraph(); + prometheusGraph.transformData(prometheusMockData.metrics); + + deployment.init(prometheusGraph.graphSpecificProperties.memory_values.data); + }); + + it('creates line on graph for deploment', () => { + expect( + graphElement().querySelectorAll('.deployment-line').length, + ).toBe(2); + }); + + it('creates hidden deploy boxes', () => { + expect( + graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box').length, + ).toBe(2); + }); + + it('hides the info boxes by default', () => { + expect( + graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, + ).toBe(2); + }); + + it('shows sha short code when tag is false', () => { + expect( + graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box').textContent.trim(), + ).toContain('testin'); + }); + + it('shows ref name when tag is true', () => { + expect( + graphElement().querySelector('.deploy-info-2-cpu_values .js-deploy-info-box').textContent.trim(), + ).toContain('tag'); + }); + + it('shows info box when moving mouse over line', () => { + deployment.mouseOverDeployInfo(deployment.data[0].xPos, 'cpu_values'); + + expect( + graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, + ).toBe(1); + + expect( + graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box.hidden'), + ).toBeNull(); + }); + + it('hides previously visible info box when moving mouse away', () => { + deployment.mouseOverDeployInfo(500, 'cpu_values'); + + expect( + graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, + ).toBe(2); + + expect( + graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box.hidden'), + ).not.toBeNull(); + }); + + describe('refText', () => { + it('returns shortened SHA', () => { + expect( + Deployments.refText({ + tag: false, + sha: '123456789', + }), + ).toBe('123456'); + }); + + it('returns tag name', () => { + expect( + Deployments.refText({ + tag: true, + ref: 'v1.0', + }), + ).toBe('v1.0'); + }); + }); +}); diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js index 4b904fc2960..25578bf1c6e 100644 --- a/spec/javascripts/monitoring/prometheus_graph_spec.js +++ b/spec/javascripts/monitoring/prometheus_graph_spec.js @@ -3,7 +3,7 @@ import PrometheusGraph from '~/monitoring/prometheus_graph'; import { prometheusMockData } from './prometheus_mock_data'; describe('PrometheusGraph', () => { - const fixtureName = 'static/environments/metrics.html.raw'; + const fixtureName = 'environments/metrics/metrics.html.raw'; const prometheusGraphContainer = '.prometheus-graph'; const prometheusGraphContents = `${prometheusGraphContainer}[graph-type=cpu_values]`; @@ -77,7 +77,7 @@ describe('PrometheusGraph', () => { }); describe('PrometheusGraphs UX states', () => { - const fixtureName = 'static/environments/metrics.html.raw'; + const fixtureName = 'environments/metrics/metrics.html.raw'; preloadFixtures(fixtureName); beforeEach(() => { diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index ca8ee04d955..cfd599f793e 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -1,10 +1,12 @@ /* eslint-disable space-before-function-paren, no-unused-expressions, no-var, object-shorthand, comma-dangle, max-len */ /* global Notes */ -require('~/notes'); -require('vendor/autosize'); -require('~/gl_form'); -require('~/lib/utils/text_utility'); +import 'vendor/autosize'; +import '~/gl_form'; +import '~/lib/utils/text_utility'; +import '~/render_gfm'; +import '~/render_math'; +import '~/notes'; (function() { window.gon || (window.gon = {}); @@ -24,10 +26,10 @@ require('~/lib/utils/text_utility'); describe('task lists', function() { beforeEach(function() { - $('form').on('submit', function(e) { + $('.js-comment-button').on('click', function(e) { e.preventDefault(); }); - this.notes = new Notes(); + this.notes = new Notes('', []); }); it('modifies the Markdown field', function() { @@ -49,7 +51,7 @@ require('~/lib/utils/text_utility'); var textarea = '.js-note-text'; beforeEach(function() { - this.notes = new Notes(); + this.notes = new Notes('', []); this.autoSizeSpy = spyOnEvent($(textarea), 'autosize:update'); spyOn(this.notes, 'renderNote').and.stub(); @@ -58,9 +60,12 @@ require('~/lib/utils/text_utility'); reset: function() {} }); - $('form').on('submit', function(e) { + $('.js-comment-button').on('click', (e) => { + const $form = $(this); e.preventDefault(); - $('.js-main-target-form').trigger('ajax:success'); + this.notes.addNote($form); + this.notes.reenableTargetFormSubmitButton(e); + this.notes.resetMainTargetForm(e); }); }); @@ -80,35 +85,78 @@ require('~/lib/utils/text_utility'); beforeEach(() => { note = { + id: 1, discussion_html: null, valid: true, - html: '<div></div>', + note: 'heya', + html: '<div>heya</div>', }; - $notesList = jasmine.createSpyObj('$notesList', ['find']); + $notesList = jasmine.createSpyObj('$notesList', [ + 'find', + 'append', + ]); notes = jasmine.createSpyObj('notes', [ 'refresh', 'isNewNote', + 'isUpdatedNote', 'collapseLongCommitList', 'updateNotesCount', + 'putConflictEditWarningInPlace' ]); notes.taskList = jasmine.createSpyObj('tasklist', ['init']); notes.note_ids = []; + notes.updatedNotesTrackingMap = {}; - spyOn(window, '$').and.returnValue($notesList); spyOn(gl.utils, 'localTimeAgo'); - spyOn(Notes, 'animateAppendNote'); - notes.isNewNote.and.returnValue(true); - - Notes.prototype.renderNote.call(notes, note); + spyOn(Notes, 'animateAppendNote').and.callThrough(); + spyOn(Notes, 'animateUpdateNote').and.callThrough(); }); - it('should query for the notes list', () => { - expect(window.$).toHaveBeenCalledWith('ul.main-notes-list'); + describe('when adding note', () => { + it('should call .animateAppendNote', () => { + notes.isNewNote.and.returnValue(true); + Notes.prototype.renderNote.call(notes, note, null, $notesList); + + expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList); + }); }); - it('should call .animateAppendNote', () => { - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList); + describe('when note was edited', () => { + it('should call .animateUpdateNote', () => { + notes.isUpdatedNote.and.returnValue(true); + const $note = $('<div>'); + $notesList.find.and.returnValue($note); + Notes.prototype.renderNote.call(notes, note, null, $notesList); + + expect(Notes.animateUpdateNote).toHaveBeenCalledWith(note.html, $note); + }); + + describe('while editing', () => { + it('should update textarea if nothing has been touched', () => { + notes.isUpdatedNote.and.returnValue(true); + const $note = $(`<div class="is-editing"> + <div class="original-note-content">initial</div> + <textarea class="js-note-text">initial</textarea> + </div>`); + $notesList.find.and.returnValue($note); + Notes.prototype.renderNote.call(notes, note, null, $notesList); + + expect($note.find('.js-note-text').val()).toEqual(note.note); + }); + + it('should call .putConflictEditWarningInPlace', () => { + notes.isUpdatedNote.and.returnValue(true); + const $note = $(`<div class="is-editing"> + <div class="original-note-content">initial</div> + <textarea class="js-note-text">different</textarea> + </div>`); + $notesList.find.and.returnValue($note); + Notes.prototype.renderNote.call(notes, note, null, $notesList); + + expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith(note, $note); + }); + }); }); }); @@ -147,14 +195,12 @@ require('~/lib/utils/text_utility'); }); describe('Discussion root note', () => { - let $notesList; let body; beforeEach(() => { body = jasmine.createSpyObj('body', ['attr']); discussionContainer = { length: 0 }; - spyOn(window, '$').and.returnValues(discussionContainer, body, $notesList); $form.closest.and.returnValues(row, $form); $form.find.and.returnValues(discussionContainer); body.attr.and.returnValue(''); @@ -162,12 +208,8 @@ require('~/lib/utils/text_utility'); Notes.prototype.renderDiscussionNote.call(notes, note, $form); }); - it('should query for the notes list', () => { - expect(window.$.calls.argsFor(2)).toEqual(['ul.main-notes-list']); - }); - it('should call Notes.animateAppendNote', () => { - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $notesList); + expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $('.main-notes-list')); }); }); @@ -175,16 +217,12 @@ require('~/lib/utils/text_utility'); beforeEach(() => { discussionContainer = { length: 1 }; - spyOn(window, '$').and.returnValues(discussionContainer); - $form.closest.and.returnValues(row); + $form.closest.and.returnValues(row, $form); + $form.find.and.returnValues(discussionContainer); Notes.prototype.renderDiscussionNote.call(notes, note, $form); }); - it('should query foor the discussion container', () => { - expect(window.$).toHaveBeenCalledWith(`.notes[data-discussion-id="${note.discussion_id}"]`); - }); - it('should call Notes.animateAppendNote', () => { expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, discussionContainer); }); @@ -193,35 +231,220 @@ require('~/lib/utils/text_utility'); describe('animateAppendNote', () => { let noteHTML; - let $note; let $notesList; + let $resultantNote; beforeEach(() => { noteHTML = '<div></div>'; - $note = jasmine.createSpyObj('$note', ['addClass', 'renderGFM', 'removeClass']); $notesList = jasmine.createSpyObj('$notesList', ['append']); - spyOn(window, '$').and.returnValue($note); - spyOn(window, 'setTimeout').and.callThrough(); - $note.addClass.and.returnValue($note); - $note.renderGFM.and.returnValue($note); + $resultantNote = Notes.animateAppendNote(noteHTML, $notesList); + }); + + it('should have `fade-in-full` class', () => { + expect($resultantNote.hasClass('fade-in-full')).toEqual(true); + }); + + it('should append note to the notes list', () => { + expect($notesList.append).toHaveBeenCalledWith($resultantNote); + }); + }); + + describe('animateUpdateNote', () => { + let noteHTML; + let $note; + let $updatedNote; + + beforeEach(() => { + noteHTML = '<div></div>'; + $note = jasmine.createSpyObj('$note', [ + 'replaceWith' + ]); + + $updatedNote = Notes.animateUpdateNote(noteHTML, $note); + }); + + it('should have `fade-in` class', () => { + expect($updatedNote.hasClass('fade-in')).toEqual(true); + }); - Notes.animateAppendNote(noteHTML, $notesList); + it('should call replaceWith on $note', () => { + expect($note.replaceWith).toHaveBeenCalledWith($updatedNote); }); + }); + + describe('postComment & updateComment', () => { + const sampleComment = 'foo'; + const updatedComment = 'bar'; + const note = { + id: 1234, + html: `<li class="note note-row-1234 timeline-entry" id="note_1234"> + <div class="note-text">${sampleComment}</div> + </li>`, + note: sampleComment, + valid: true + }; + let $form; + let $notesContainer; - it('should init the note jquery object', () => { - expect(window.$).toHaveBeenCalledWith(noteHTML); + beforeEach(() => { + this.notes = new Notes('', []); + window.gon.current_username = 'root'; + window.gon.current_user_fullname = 'Administrator'; + $form = $('form.js-main-target-form'); + $notesContainer = $('ul.main-notes-list'); + $form.find('textarea.js-note-text').val(sampleComment); }); - it('should call addClass', () => { - expect($note.addClass).toHaveBeenCalledWith('fade-in'); + it('should show placeholder note while new comment is being posted', () => { + $('.js-comment-button').click(); + expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); }); - it('should call renderGFM', () => { - expect($note.renderGFM).toHaveBeenCalledWith(); + + it('should remove placeholder note when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($notesContainer.find('.note.being-posted').length).toEqual(0); }); - it('should append note to the notes list', () => { - expect($notesList.append).toHaveBeenCalledWith($note); + it('should show actual note element when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(true); + }); + + it('should reset Form when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($form.find('textarea.js-note-text').val()).toEqual(''); + }); + + it('should show flash error message when new comment failed to be posted', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.reject(); + expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); + }); + + it('should show flash error message when comment failed to be updated', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + const $noteEl = $notesContainer.find(`#note_${note.id}`); + $noteEl.find('.js-note-edit').click(); + $noteEl.find('textarea.js-note-text').val(updatedComment); + $noteEl.find('.js-comment-save-button').click(); + + deferred.reject(); + const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); + expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals + expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original + expect($('.flash-container').is(':visible')).toEqual(true); // Flash error message shown + }); + }); + + describe('getFormData', () => { + it('should return form metadata object from form reference', () => { + this.notes = new Notes('', []); + + const $form = $('form'); + const sampleComment = 'foobar'; + $form.find('textarea.js-note-text').val(sampleComment); + const { formData, formContent, formAction } = this.notes.getFormData($form); + + expect(formData.indexOf(sampleComment) > -1).toBe(true); + expect(formContent).toEqual(sampleComment); + expect(formAction).toEqual($form.attr('action')); + }); + }); + + describe('hasSlashCommands', () => { + beforeEach(() => { + this.notes = new Notes('', []); + }); + + it('should return true when comment has slash commands', () => { + const sampleComment = '/wip /milestone %1.0 /merge /unassign Merging this'; + const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + + expect(hasSlashCommands).toBeTruthy(); + }); + + it('should return false when comment does NOT have any slash commands', () => { + const sampleComment = 'Looking good, Awesome!'; + const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + + expect(hasSlashCommands).toBeFalsy(); + }); + }); + + describe('stripSlashCommands', () => { + const REGEX_SLASH_COMMANDS = /\/\w+/g; + + it('should strip slash commands from the comment', () => { + this.notes = new Notes(); + const sampleComment = '/wip /milestone %1.0 /merge /unassign Merging this'; + const stripedComment = this.notes.stripSlashCommands(sampleComment); + + expect(REGEX_SLASH_COMMANDS.test(stripedComment)).toBeFalsy(); + }); + }); + + describe('createPlaceholderNote', () => { + const sampleComment = 'foobar'; + const uniqueId = 'b1234-a4567'; + const currentUsername = 'root'; + const currentUserFullname = 'Administrator'; + + beforeEach(() => { + this.notes = new Notes('', []); + }); + + it('should return constructed placeholder element for regular note based on form contents', () => { + const $tempNote = this.notes.createPlaceholderNote({ + formContent: sampleComment, + uniqueId, + isDiscussionNote: false, + currentUsername, + currentUserFullname + }); + const $tempNoteHeader = $tempNote.find('.note-header'); + + expect($tempNote.prop('nodeName')).toEqual('LI'); + expect($tempNote.attr('id')).toEqual(uniqueId); + $tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() { + expect($(this).attr('href')).toEqual(`/${currentUsername}`); + }); + expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy(); + expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname); + expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`); + expect($tempNote.find('.note-body .note-text').text().trim()).toEqual(sampleComment); + }); + + it('should return constructed placeholder element for discussion note based on form contents', () => { + const $tempNote = this.notes.createPlaceholderNote({ + formContent: sampleComment, + uniqueId, + isDiscussionNote: true, + currentUsername, + currentUserFullname + }); + + expect($tempNote.prop('nodeName')).toEqual('LI'); + expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); }); }); }); diff --git a/spec/javascripts/pipelines/stage_spec.js b/spec/javascripts/pipelines/stage_spec.js index 2f1154bd999..a4f32a1faed 100644 --- a/spec/javascripts/pipelines/stage_spec.js +++ b/spec/javascripts/pipelines/stage_spec.js @@ -1,81 +1,86 @@ import Vue from 'vue'; -import { SUCCESS_SVG } from '~/ci_status_icons'; -import Stage from '~/pipelines/components/stage'; +import stage from '~/pipelines/components/stage.vue'; + +describe('Pipelines stage component', () => { + let StageComponent; + let component; + + beforeEach(() => { + StageComponent = Vue.extend(stage); + + component = new StageComponent({ + propsData: { + stage: { + status: { + group: 'success', + icon: 'icon_status_success', + title: 'success', + }, + dropdown_path: 'foo', + }, + updateDropdown: false, + }, + }).$mount(); + }); -function minify(string) { - return string.replace(/\s/g, ''); -} + it('should render a dropdown with the status icon', () => { + expect(component.$el.getAttribute('class')).toEqual('dropdown'); + expect(component.$el.querySelector('svg')).toBeDefined(); + expect(component.$el.querySelector('button').getAttribute('data-toggle')).toEqual('dropdown'); + }); -describe('Pipelines Stage', () => { - describe('data', () => { - let stageReturnValue; + describe('with successfull request', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify({ html: 'foo' }), { + status: 200, + })); + }; beforeEach(() => { - stageReturnValue = Stage.data(); + Vue.http.interceptors.push(interceptor); }); - it('should return object with .builds and .spinner', () => { - expect(stageReturnValue).toEqual({ - builds: '', - spinner: '<span class="fa fa-spinner fa-spin"></span>', - }); + afterEach(() => { + Vue.http.interceptors = _.without( + Vue.http.interceptors, interceptor, + ); }); - }); - describe('computed', () => { - describe('svgHTML', function () { - let stage; - let svgHTML; + it('should render the received data', (done) => { + component.$el.querySelector('button').click(); - beforeEach(() => { - stage = { stage: { status: { icon: 'icon_status_success' } } }; - - svgHTML = Stage.computed.svgHTML.call(stage); - }); - - it("should return the correct icon for the stage's status", () => { - expect(svgHTML).toBe(SUCCESS_SVG); - }); + setTimeout(() => { + expect( + component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(), + ).toEqual('foo'); + done(); + }, 0); }); }); - describe('when mounted', () => { - let StageComponent; - let renderedComponent; - let stage; + describe('when request fails', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify({}), { + status: 500, + })); + }; beforeEach(() => { - stage = { status: { icon: 'icon_status_success' } }; - - StageComponent = Vue.extend(Stage); - - renderedComponent = new StageComponent({ - propsData: { - stage, - }, - }).$mount(); + Vue.http.interceptors.push(interceptor); }); - it('should render the correct status svg', () => { - const minifiedComponent = minify(renderedComponent.$el.outerHTML); - const expectedSVG = minify(SUCCESS_SVG); - - expect(minifiedComponent).toContain(expectedSVG); + afterEach(() => { + Vue.http.interceptors = _.without( + Vue.http.interceptors, interceptor, + ); }); - }); - - describe('when request fails', () => { - it('closes dropdown', () => { - spyOn($, 'ajax').and.callFake(options => options.error()); - const StageComponent = Vue.extend(Stage); - const component = new StageComponent({ - propsData: { stage: { status: { icon: 'foo' } } }, - }).$mount(); + it('should close the dropdown', () => { + component.$el.click(); - expect( - component.$el.classList.contains('open'), - ).toEqual(false); + setTimeout(() => { + expect(component.$el.classList.contains('open')).toEqual(false); + }, 0); }); }); }); diff --git a/spec/javascripts/raven/index_spec.js b/spec/javascripts/raven/index_spec.js new file mode 100644 index 00000000000..b5662cd0331 --- /dev/null +++ b/spec/javascripts/raven/index_spec.js @@ -0,0 +1,42 @@ +import RavenConfig from '~/raven/raven_config'; +import index from '~/raven/index'; + +describe('RavenConfig options', () => { + let sentryDsn; + let currentUserId; + let gitlabUrl; + let isProduction; + let indexReturnValue; + + beforeEach(() => { + sentryDsn = 'sentryDsn'; + currentUserId = 'currentUserId'; + gitlabUrl = 'gitlabUrl'; + isProduction = 'isProduction'; + + window.gon = { + sentry_dsn: sentryDsn, + current_user_id: currentUserId, + gitlab_url: gitlabUrl, + }; + + process.env.NODE_ENV = isProduction; + + spyOn(RavenConfig, 'init'); + + indexReturnValue = index(); + }); + + it('should init with .sentryDsn, .currentUserId, .whitelistUrls and .isProduction', () => { + expect(RavenConfig.init).toHaveBeenCalledWith({ + sentryDsn, + currentUserId, + whitelistUrls: [gitlabUrl], + isProduction, + }); + }); + + it('should return RavenConfig', () => { + expect(indexReturnValue).toBe(RavenConfig); + }); +}); diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js new file mode 100644 index 00000000000..a2d720760fc --- /dev/null +++ b/spec/javascripts/raven/raven_config_spec.js @@ -0,0 +1,276 @@ +import Raven from 'raven-js'; +import RavenConfig from '~/raven/raven_config'; + +describe('RavenConfig', () => { + describe('IGNORE_ERRORS', () => { + it('should be an array of strings', () => { + const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); + + expect(areStrings).toBe(true); + }); + }); + + describe('IGNORE_URLS', () => { + it('should be an array of regexps', () => { + const areRegExps = RavenConfig.IGNORE_URLS.every(url => url instanceof RegExp); + + expect(areRegExps).toBe(true); + }); + }); + + describe('SAMPLE_RATE', () => { + it('should be a finite number', () => { + expect(typeof RavenConfig.SAMPLE_RATE).toEqual('number'); + }); + }); + + describe('init', () => { + let options; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: 1, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + + spyOn(RavenConfig, 'configure'); + spyOn(RavenConfig, 'bindRavenErrors'); + spyOn(RavenConfig, 'setUser'); + + RavenConfig.init(options); + }); + + it('should set the options property', () => { + expect(RavenConfig.options).toEqual(options); + }); + + it('should call the configure method', () => { + expect(RavenConfig.configure).toHaveBeenCalled(); + }); + + it('should call the error bindings method', () => { + expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); + }); + + it('should call setUser', () => { + expect(RavenConfig.setUser).toHaveBeenCalled(); + }); + + it('should not call setUser if there is no current user ID', () => { + RavenConfig.setUser.calls.reset(); + + RavenConfig.init({ + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: undefined, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }); + + expect(RavenConfig.setUser).not.toHaveBeenCalled(); + }); + }); + + describe('configure', () => { + let options; + let raven; + let ravenConfig; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + + ravenConfig = jasmine.createSpyObj('ravenConfig', ['shouldSendSample']); + raven = jasmine.createSpyObj('raven', ['install']); + + spyOn(Raven, 'config').and.returnValue(raven); + + ravenConfig.options = options; + ravenConfig.IGNORE_ERRORS = 'ignore_errors'; + ravenConfig.IGNORE_URLS = 'ignore_urls'; + + RavenConfig.configure.call(ravenConfig); + }); + + it('should call Raven.config', () => { + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'production', + ignoreErrors: ravenConfig.IGNORE_ERRORS, + ignoreUrls: ravenConfig.IGNORE_URLS, + shouldSendCallback: jasmine.any(Function), + }); + }); + + it('should call Raven.install', () => { + expect(raven.install).toHaveBeenCalled(); + }); + + it('should set .environment to development if isProduction is false', () => { + ravenConfig.options.isProduction = false; + + RavenConfig.configure.call(ravenConfig); + + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'development', + ignoreErrors: ravenConfig.IGNORE_ERRORS, + ignoreUrls: ravenConfig.IGNORE_URLS, + shouldSendCallback: jasmine.any(Function), + }); + }); + }); + + describe('setUser', () => { + let ravenConfig; + + beforeEach(() => { + ravenConfig = { options: { currentUserId: 1 } }; + spyOn(Raven, 'setUserContext'); + + RavenConfig.setUser.call(ravenConfig); + }); + + it('should call .setUserContext', function () { + expect(Raven.setUserContext).toHaveBeenCalledWith({ + id: ravenConfig.options.currentUserId, + }); + }); + }); + + describe('bindRavenErrors', () => { + let $document; + let $; + + beforeEach(() => { + $document = jasmine.createSpyObj('$document', ['on']); + $ = jasmine.createSpy('$').and.returnValue($document); + + window.$ = $; + + RavenConfig.bindRavenErrors(); + }); + + it('should call .on', function () { + expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors); + }); + }); + + describe('handleRavenErrors', () => { + let event; + let req; + let config; + let err; + + beforeEach(() => { + event = {}; + req = { status: 'status', responseText: 'responseText', statusText: 'statusText' }; + config = { type: 'type', url: 'url', data: 'data' }; + err = {}; + + spyOn(Raven, 'captureMessage'); + + RavenConfig.handleRavenErrors(event, req, config, err); + }); + + it('should call Raven.captureMessage', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: err, + event, + }, + }); + }); + + describe('if no err is provided', () => { + beforeEach(() => { + Raven.captureMessage.calls.reset(); + + RavenConfig.handleRavenErrors(event, req, config); + }); + + it('should use req.statusText as the error value', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(req.statusText, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: req.statusText, + event, + }, + }); + }); + }); + + describe('if no req.responseText is provided', () => { + beforeEach(() => { + req.responseText = undefined; + + Raven.captureMessage.calls.reset(); + + RavenConfig.handleRavenErrors(event, req, config, err); + }); + + it('should use `Unknown response text` as the response', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: 'Unknown response text', + error: err, + event, + }, + }); + }); + }); + }); + + describe('shouldSendSample', () => { + let randomNumber; + + beforeEach(() => { + RavenConfig.SAMPLE_RATE = 50; + + spyOn(Math, 'random').and.callFake(() => randomNumber); + }); + + it('should call Math.random', () => { + RavenConfig.shouldSendSample(); + + expect(Math.random).toHaveBeenCalled(); + }); + + it('should return true if the sample rate is greater than the random number * 100', () => { + randomNumber = 0.1; + + expect(RavenConfig.shouldSendSample()).toBe(true); + }); + + it('should return false if the sample rate is less than the random number * 100', () => { + randomNumber = 0.9; + + expect(RavenConfig.shouldSendSample()).toBe(false); + }); + + it('should return true if the sample rate is equal to the random number * 100', () => { + randomNumber = 0.5; + + expect(RavenConfig.shouldSendSample()).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/sidebar/assignee_title_spec.js b/spec/javascripts/sidebar/assignee_title_spec.js new file mode 100644 index 00000000000..5b5b1bf4140 --- /dev/null +++ b/spec/javascripts/sidebar/assignee_title_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import AssigneeTitle from '~/sidebar/components/assignees/assignee_title'; + +describe('AssigneeTitle component', () => { + let component; + let AssigneeTitleComponent; + + beforeEach(() => { + AssigneeTitleComponent = Vue.extend(AssigneeTitle); + }); + + describe('assignee title', () => { + it('renders assignee', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 1, + editable: false, + }, + }).$mount(); + + expect(component.$el.innerText.trim()).toEqual('Assignee'); + }); + + it('renders 2 assignees', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 2, + editable: false, + }, + }).$mount(); + + expect(component.$el.innerText.trim()).toEqual('2 Assignees'); + }); + }); + + it('does not render spinner by default', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.fa')).toBeNull(); + }); + + it('renders spinner when loading', () => { + component = new AssigneeTitleComponent({ + propsData: { + loading: true, + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.fa')).not.toBeNull(); + }); + + it('does not render edit link when not editable', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.edit-link')).toBeNull(); + }); + + it('renders edit link when editable', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelector('.edit-link')).not.toBeNull(); + }); +}); diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js new file mode 100644 index 00000000000..c9453a21189 --- /dev/null +++ b/spec/javascripts/sidebar/assignees_spec.js @@ -0,0 +1,272 @@ +import Vue from 'vue'; +import Assignee from '~/sidebar/components/assignees/assignees'; +import UsersMock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +describe('Assignee component', () => { + let component; + let AssigneeComponent; + + beforeEach(() => { + AssigneeComponent = Vue.extend(Assignee); + }); + + describe('No assignees/users', () => { + it('displays no assignee icon when collapsed', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(1); + expect(collapsed.children[0].getAttribute('aria-label')).toEqual('No Assignee'); + expect(collapsed.children[0].classList.contains('fa')).toEqual(true); + expect(collapsed.children[0].classList.contains('fa-user')).toEqual(true); + }); + + it('displays only "No assignee" when no users are assigned and the issue is read-only', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }, + }).$mount(); + const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim(); + + expect(componentTextNoUsers).toBe('No assignee'); + expect(componentTextNoUsers.indexOf('assign yourself')).toEqual(-1); + }); + + it('displays only "No assignee" when no users are assigned and the issue can be edited', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: true, + }, + }).$mount(); + const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim(); + + expect(componentTextNoUsers.indexOf('No assignee')).toEqual(0); + expect(componentTextNoUsers.indexOf('assign yourself')).toBeGreaterThan(0); + }); + + it('emits the assign-self event when "assign yourself" is clicked', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: true, + }, + }).$mount(); + + spyOn(component, '$emit'); + component.$el.querySelector('.assign-yourself .btn-link').click(); + expect(component.$emit).toHaveBeenCalledWith('assign-self'); + }); + }); + + describe('One assignee/user', () => { + it('displays one assignee icon when collapsed', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [ + UsersMock.user, + ], + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + const assignee = collapsed.children[0]; + expect(collapsed.childElementCount).toEqual(1); + expect(assignee.querySelector('.avatar').getAttribute('src')).toEqual(UsersMock.user.avatar); + expect(assignee.querySelector('.avatar').getAttribute('alt')).toEqual(`${UsersMock.user.name}'s avatar`); + expect(assignee.querySelector('.author').innerText.trim()).toEqual(UsersMock.user.name); + }); + + it('Shows one user with avatar, username and author name', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000/', + users: [ + UsersMock.user, + ], + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelector('.author_link')).not.toBeNull(); + // The image + expect(component.$el.querySelector('.author_link img').getAttribute('src')).toEqual(UsersMock.user.avatar); + // Author name + expect(component.$el.querySelector('.author_link .author').innerText.trim()).toEqual(UsersMock.user.name); + // Username + expect(component.$el.querySelector('.author_link .username').innerText.trim()).toEqual(`@${UsersMock.user.username}`); + }); + + it('has the root url present in the assigneeUrl method', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000/', + users: [ + UsersMock.user, + ], + editable: true, + }, + }).$mount(); + + expect(component.assigneeUrl(UsersMock.user).indexOf('http://localhost:3000/')).not.toEqual(-1); + }); + }); + + describe('Two or more assignees/users', () => { + it('displays two assignee icons when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(2); + + const first = collapsed.children[0]; + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar); + expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); + expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); + + const second = collapsed.children[1]; + expect(second.querySelector('.avatar').getAttribute('src')).toEqual(users[1].avatar); + expect(second.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[1].name}'s avatar`); + expect(second.querySelector('.author').innerText.trim()).toEqual(users[1].name); + }); + + it('displays one assignee icon and counter when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(3); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(2); + + const first = collapsed.children[0]; + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar); + expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); + expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); + + const second = collapsed.children[1]; + expect(second.querySelector('.avatar-counter').innerText.trim()).toEqual('+2'); + }); + + it('Shows two assignees', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelectorAll('.user-item').length).toEqual(users.length); + expect(component.$el.querySelector('.user-list-more')).toBe(null); + }); + + it('Shows the "show-less" assignees label', (done) => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelectorAll('.user-item').length).toEqual(component.defaultRenderCount); + expect(component.$el.querySelector('.user-list-more')).not.toBe(null); + const usersLabelExpectation = users.length - component.defaultRenderCount; + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .not.toBe(`+${usersLabelExpectation} more`); + component.toggleShowLess(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + + it('Shows the "show-less" when "n+ more " label is clicked', (done) => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + component.$el.querySelector('.user-list-more .btn-link').click(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + + it('gets the count of avatar via a computed property ', () => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.sidebarAvatarCounter).toEqual(`+${users.length - 1}`); + }); + + describe('n+ more label', () => { + beforeEach(() => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + }); + + it('shows "+1 more" label', () => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('+ 1 more'); + }); + + it('shows "show less" label', (done) => { + component.toggleShowLess(); + + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js new file mode 100644 index 00000000000..9fc8667ecc9 --- /dev/null +++ b/spec/javascripts/sidebar/mock_data.js @@ -0,0 +1,109 @@ +/* eslint-disable quote-props*/ + +const sidebarMockData = { + 'GET': { + '/gitlab-org/gitlab-shell/issues/5.json': { + id: 45, + iid: 5, + author_id: 23, + description: 'Nulla ullam commodi delectus adipisci quis sit.', + lock_version: null, + milestone_id: 21, + position: 0, + state: 'closed', + title: 'Vel et nulla voluptatibus corporis dolor iste saepe laborum.', + updated_by_id: 1, + created_at: '2017-02-02T21: 49: 49.664Z', + updated_at: '2017-05-03T22: 26: 03.760Z', + deleted_at: null, + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + branch_name: null, + confidential: false, + assignees: [ + { + name: 'User 0', + username: 'user0', + id: 22, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/user0', + }, + { + name: 'Marguerite Bartell', + username: 'tajuana', + id: 18, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/tajuana', + }, + { + name: 'Laureen Ritchie', + username: 'michaele.will', + id: 16, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/michaele.will', + }, + ], + due_date: null, + moved_to_id: null, + project_id: 4, + weight: null, + milestone: { + id: 21, + iid: 1, + project_id: 4, + title: 'v0.0', + description: 'Molestiae commodi laboriosam odio sunt eaque reprehenderit.', + state: 'active', + created_at: '2017-02-02T21: 49: 30.530Z', + updated_at: '2017-02-02T21: 49: 30.530Z', + due_date: null, + start_date: null, + }, + labels: [], + }, + }, + 'PUT': { + '/gitlab-org/gitlab-shell/issues/5.json': { + data: {}, + }, + }, +}; + +export default { + mediator: { + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + editable: true, + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + rootPath: '/', + }, + time: { + time_estimate: 3600, + total_time_spent: 0, + human_time_estimate: '1h', + human_total_time_spent: null, + }, + user: { + avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 1, + name: 'Administrator', + username: 'root', + }, + + sidebarMockInterceptor(request, next) { + const body = sidebarMockData[request.method.toUpperCase()][request.url]; + + next(request.respondWith(JSON.stringify(body), { + status: 200, + })); + }, +}; diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js new file mode 100644 index 00000000000..e0df0a3228f --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('sidebar assignees', () => { + let component; + let SidebarAssigneeComponent; + preloadFixtures('issues/open-issue.html.raw'); + + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + SidebarAssigneeComponent = Vue.extend(SidebarAssignees); + spyOn(SidebarMediator.prototype, 'saveAssignees').and.callThrough(); + spyOn(SidebarMediator.prototype, 'assignYourself').and.callThrough(); + this.mediator = new SidebarMediator(Mock.mediator); + loadFixtures('issues/open-issue.html.raw'); + this.sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator when saves the assignees', () => { + component = new SidebarAssigneeComponent() + .$mount(this.sidebarAssigneesEl); + component.saveAssignees(); + + expect(SidebarMediator.prototype.saveAssignees).toHaveBeenCalled(); + }); + + it('calls the mediator when "assignSelf" method is called', () => { + component = new SidebarAssigneeComponent() + .$mount(this.sidebarAssigneesEl); + component.assignSelf(); + + expect(SidebarMediator.prototype.assignYourself).toHaveBeenCalled(); + expect(this.mediator.store.assignees.length).toEqual(1); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_bundle_spec.js b/spec/javascripts/sidebar/sidebar_bundle_spec.js new file mode 100644 index 00000000000..7760b34e071 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_bundle_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import SidebarBundleDomContentLoaded from '~/sidebar/sidebar_bundle'; +import SidebarTimeTracking from '~/sidebar/components/time_tracking/sidebar_time_tracking'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('sidebar bundle', () => { + gl.sidebarOptions = Mock.mediator; + + beforeEach(() => { + spyOn(SidebarTimeTracking.methods, 'listenForSlashCommands').and.callFake(() => { }); + preloadFixtures('issues/open-issue.html.raw'); + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + loadFixtures('issues/open-issue.html.raw'); + spyOn(Vue.prototype, '$mount'); + SidebarBundleDomContentLoaded(); + this.mediator = new SidebarMediator(); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('the mediator should be already defined with some data', () => { + SidebarBundleDomContentLoaded(); + + expect(this.mediator.store).toBeDefined(); + expect(this.mediator.service).toBeDefined(); + expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); + expect(this.mediator.store.rootPath).toEqual(Mock.mediator.rootPath); + expect(this.mediator.store.endPoint).toEqual(Mock.mediator.endPoint); + expect(this.mediator.store.editable).toEqual(Mock.mediator.editable); + }); + + it('the sidebar time tracking and assignees components to have been mounted', () => { + expect(Vue.prototype.$mount).toHaveBeenCalledTimes(2); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js new file mode 100644 index 00000000000..2b00fa17334 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import Mock from './mock_data'; + +describe('Sidebar mediator', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.mediator = new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('assigns yourself ', () => { + this.mediator.assignYourself(); + + expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); + expect(this.mediator.store.assignees[0]).toEqual(Mock.mediator.currentUser); + }); + + it('saves assignees', (done) => { + this.mediator.saveAssignees('issue[assignee_ids]') + .then((resp) => { + expect(resp.status).toEqual(200); + done(); + }) + .catch(() => {}); + }); + + it('fetches the data', () => { + spyOn(this.mediator.service, 'get').and.callThrough(); + this.mediator.fetch(); + expect(this.mediator.service.get).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js new file mode 100644 index 00000000000..d41162096a6 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import Mock from './mock_data'; + +describe('Sidebar service', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.service = new SidebarService('/gitlab-org/gitlab-shell/issues/5.json'); + }); + + afterEach(() => { + SidebarService.singleton = null; + }); + + it('gets the data', (done) => { + this.service.get() + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(() => {}); + }); + + it('updates the data', (done) => { + this.service.update('issue[assignee_ids]', [1]) + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(() => {}); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js new file mode 100644 index 00000000000..29facf483b5 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -0,0 +1,80 @@ +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +describe('Sidebar store', () => { + const assignee = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + const anotherAssignee = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + beforeEach(() => { + this.store = new SidebarStore({ + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + editable: true, + rootPath: '/', + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + }); + }); + + afterEach(() => { + SidebarStore.singleton = null; + }); + + it('adds a new assignee', () => { + this.store.addAssignee(assignee); + expect(this.store.assignees.length).toEqual(1); + }); + + it('removes an assignee', () => { + this.store.removeAssignee(assignee); + expect(this.store.assignees.length).toEqual(0); + }); + + it('finds an existent assignee', () => { + let foundAssignee; + + this.store.addAssignee(assignee); + foundAssignee = this.store.findAssignee(assignee); + expect(foundAssignee).toBeDefined(); + expect(foundAssignee).toEqual(assignee); + foundAssignee = this.store.findAssignee(anotherAssignee); + expect(foundAssignee).toBeUndefined(); + }); + + it('removes all assignees', () => { + this.store.removeAllAssignees(); + expect(this.store.assignees.length).toEqual(0); + }); + + it('set assigned data', () => { + const users = { + assignees: UsersMockHelper.createNumberRandomUsers(3), + }; + + this.store.setAssigneeData(users); + expect(this.store.assignees.length).toEqual(3); + }); + + it('set time tracking data', () => { + this.store.setTimeTrackingData(Mock.time); + expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); + expect(this.store.totalTimeSpent).toEqual(Mock.time.total_time_spent); + expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); + expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); + }); +}); diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js b/spec/javascripts/signin_tabs_memoizer_spec.js index d83d9a57b42..5b4f5933b34 100644 --- a/spec/javascripts/signin_tabs_memoizer_spec.js +++ b/spec/javascripts/signin_tabs_memoizer_spec.js @@ -1,3 +1,5 @@ +import AccessorUtilities from '~/lib/utils/accessor'; + require('~/signin_tabs_memoizer'); ((global) => { @@ -19,6 +21,8 @@ require('~/signin_tabs_memoizer'); beforeEach(() => { loadFixtures(fixtureTemplate); + + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); }); it('does nothing if no tab was previously selected', () => { @@ -49,5 +53,91 @@ require('~/signin_tabs_memoizer'); expect(memo.readData()).toEqual('#standard'); }); + + describe('class constructor', () => { + beforeEach(() => { + memo = createMemoizer(); + }); + + it('should set .isLocalStorageAvailable', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(memo.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('saveData', () => { + beforeEach(() => { + memo = { + currentTabKey, + }; + + spyOn(localStorage, 'setItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = false; + + global.ActiveTabMemoizer.prototype.saveData.call(memo); + }); + + it('should not call .setItem', () => { + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + const value = 'value'; + + beforeEach(function () { + memo.isLocalStorageAvailable = true; + + global.ActiveTabMemoizer.prototype.saveData.call(memo, value); + }); + + it('should call .setItem', () => { + expect(localStorage.setItem).toHaveBeenCalledWith(currentTabKey, value); + }); + }); + }); + + describe('readData', () => { + const itemValue = 'itemValue'; + let readData; + + beforeEach(() => { + memo = { + currentTabKey, + }; + + spyOn(localStorage, 'getItem').and.returnValue(itemValue); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = false; + + readData = global.ActiveTabMemoizer.prototype.readData.call(memo); + }); + + it('should not call .getItem and should return `null`', () => { + expect(localStorage.getItem).not.toHaveBeenCalled(); + expect(readData).toBe(null); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = true; + + readData = global.ActiveTabMemoizer.prototype.readData.call(memo); + }); + + it('should call .getItem and return the localStorage value', () => { + expect(window.localStorage.getItem).toHaveBeenCalledWith(currentTabKey); + expect(readData).toBe(itemValue); + }); + }); + }); }); })(window); diff --git a/spec/javascripts/subbable_resource_spec.js b/spec/javascripts/subbable_resource_spec.js deleted file mode 100644 index 454386697f5..00000000000 --- a/spec/javascripts/subbable_resource_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable max-len, arrow-parens, comma-dangle */ - -require('~/subbable_resource'); - -/* -* Test that each rest verb calls the publish and subscribe function and passes the correct value back -* -* -* */ -((global) => { - describe('Subbable Resource', function () { - describe('PubSub', function () { - beforeEach(function () { - this.MockResource = new global.SubbableResource('https://example.com'); - }); - it('should successfully add a single subscriber', function () { - const callback = () => {}; - this.MockResource.subscribe(callback); - - expect(this.MockResource.subscribers.length).toBe(1); - expect(this.MockResource.subscribers[0]).toBe(callback); - }); - - it('should successfully add multiple subscribers', function () { - const callbackOne = () => {}; - const callbackTwo = () => {}; - const callbackThree = () => {}; - - this.MockResource.subscribe(callbackOne); - this.MockResource.subscribe(callbackTwo); - this.MockResource.subscribe(callbackThree); - - expect(this.MockResource.subscribers.length).toBe(3); - }); - - it('should successfully publish an update to a single subscriber', function () { - const state = { myprop: 1 }; - - const callbacks = { - one: (data) => expect(data.myprop).toBe(2), - two: (data) => expect(data.myprop).toBe(2), - three: (data) => expect(data.myprop).toBe(2) - }; - - const spyOne = spyOn(callbacks, 'one'); - const spyTwo = spyOn(callbacks, 'two'); - const spyThree = spyOn(callbacks, 'three'); - - this.MockResource.subscribe(callbacks.one); - this.MockResource.subscribe(callbacks.two); - this.MockResource.subscribe(callbacks.three); - - state.myprop += 1; - - this.MockResource.publish(state); - - expect(spyOne).toHaveBeenCalled(); - expect(spyTwo).toHaveBeenCalled(); - expect(spyThree).toHaveBeenCalled(); - }); - }); - }); -})(window.gl || (window.gl = {})); diff --git a/spec/javascripts/vue_shared/translate_spec.js b/spec/javascripts/vue_shared/translate_spec.js new file mode 100644 index 00000000000..cbb3cbdff46 --- /dev/null +++ b/spec/javascripts/vue_shared/translate_spec.js @@ -0,0 +1,90 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; + +Vue.use(Translate); + +describe('Vue translate filter', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + + document.body.appendChild(el); + }); + + it('translate single text', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ __('testing') }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('testing'); + + done(); + }); + }); + + it('translate plural text with single count', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ n__('%d day', '%d days', 1) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('1 day'); + + done(); + }); + }); + + it('translate plural text with multiple count', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ n__('%d day', '%d days', 2) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('2 days'); + + done(); + }); + }); + + it('translate plural without replacing any text', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ n__('day', 'days', 2) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('days'); + + done(); + }); + }); +}); diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb index 8a6fe1ad6a3..7c4a0f32c7b 100644 --- a/spec/lib/banzai/filter/redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/redactor_filter_spec.rb @@ -113,7 +113,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do it 'allows references for assignee' do assignee = create(:user) project = create(:empty_project, :public) - issue = create(:issue, :confidential, project: project, assignee: assignee) + issue = create(:issue, :confidential, project: project, assignees: [assignee]) link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue') doc = filter(link, current_user: assignee) diff --git a/spec/lib/banzai/renderer_spec.rb b/spec/lib/banzai/renderer_spec.rb index e6f8d2a1fed..0e094405e33 100644 --- a/spec/lib/banzai/renderer_spec.rb +++ b/spec/lib/banzai/renderer_spec.rb @@ -11,7 +11,7 @@ describe Banzai::Renderer do end describe '#render_field' do - let(:renderer) { Banzai::Renderer } + let(:renderer) { described_class } subject { renderer.render_field(object, :field) } context 'with a stale cache' do diff --git a/spec/lib/constraints/group_url_constrainer_spec.rb b/spec/lib/constraints/group_url_constrainer_spec.rb index 96dacdc5cd2..db680489a8d 100644 --- a/spec/lib/constraints/group_url_constrainer_spec.rb +++ b/spec/lib/constraints/group_url_constrainer_spec.rb @@ -17,14 +17,49 @@ describe GroupUrlConstrainer, lib: true do it { expect(subject.matches?(request)).to be_truthy } end + context 'valid request for nested group with reserved top level name' do + let!(:nested_group) { create(:group, path: 'api', parent: group) } + let!(:request) { build_request('gitlab/api') } + + it { expect(subject.matches?(request)).to be_truthy } + end + context 'invalid request' do let(:request) { build_request('foo') } it { expect(subject.matches?(request)).to be_falsey } end + + context 'when the request matches a redirect route' do + context 'for a root group' do + let!(:redirect_route) { group.redirect_routes.create!(path: 'gitlabb') } + + context 'and is a GET request' do + let(:request) { build_request(redirect_route.path) } + + it { expect(subject.matches?(request)).to be_truthy } + end + + context 'and is NOT a GET request' do + let(:request) { build_request(redirect_route.path, 'POST') } + + it { expect(subject.matches?(request)).to be_falsey } + end + end + + context 'for a nested group' do + let!(:nested_group) { create(:group, path: 'nested', parent: group) } + let!(:redirect_route) { nested_group.redirect_routes.create!(path: 'gitlabb/nested') } + let(:request) { build_request(redirect_route.path) } + + it { expect(subject.matches?(request)).to be_truthy } + end + end end - def build_request(path) - double(:request, params: { id: path }) + def build_request(path, method = 'GET') + double(:request, + 'get?': (method == 'GET'), + params: { id: path }) end end diff --git a/spec/lib/constraints/project_url_constrainer_spec.rb b/spec/lib/constraints/project_url_constrainer_spec.rb index 4f25ad88960..b6884e37aa3 100644 --- a/spec/lib/constraints/project_url_constrainer_spec.rb +++ b/spec/lib/constraints/project_url_constrainer_spec.rb @@ -24,9 +24,26 @@ describe ProjectUrlConstrainer, lib: true do it { expect(subject.matches?(request)).to be_falsey } end end + + context 'when the request matches a redirect route' do + let(:old_project_path) { 'old_project_path' } + let!(:redirect_route) { project.redirect_routes.create!(path: "#{namespace.full_path}/#{old_project_path}") } + + context 'and is a GET request' do + let(:request) { build_request(namespace.full_path, old_project_path) } + it { expect(subject.matches?(request)).to be_truthy } + end + + context 'and is NOT a GET request' do + let(:request) { build_request(namespace.full_path, old_project_path, 'POST') } + it { expect(subject.matches?(request)).to be_falsey } + end + end end - def build_request(namespace, project) - double(:request, params: { namespace_id: namespace, id: project }) + def build_request(namespace, project, method = 'GET') + double(:request, + 'get?': (method == 'GET'), + params: { namespace_id: namespace, id: project }) end end diff --git a/spec/lib/constraints/user_url_constrainer_spec.rb b/spec/lib/constraints/user_url_constrainer_spec.rb index 207b6fe6c9e..ed69b830979 100644 --- a/spec/lib/constraints/user_url_constrainer_spec.rb +++ b/spec/lib/constraints/user_url_constrainer_spec.rb @@ -15,9 +15,26 @@ describe UserUrlConstrainer, lib: true do it { expect(subject.matches?(request)).to be_falsey } end + + context 'when the request matches a redirect route' do + let(:old_project_path) { 'old_project_path' } + let!(:redirect_route) { user.namespace.redirect_routes.create!(path: 'foo') } + + context 'and is a GET request' do + let(:request) { build_request(redirect_route.path) } + it { expect(subject.matches?(request)).to be_truthy } + end + + context 'and is NOT a GET request' do + let(:request) { build_request(redirect_route.path, 'POST') } + it { expect(subject.matches?(request)).to be_falsey } + end + end end - def build_request(username) - double(:request, params: { username: username }) + def build_request(username, method = 'GET') + double(:request, + 'get?': (method == 'GET'), + params: { username: username }) end end diff --git a/spec/lib/gitlab/changes_list_spec.rb b/spec/lib/gitlab/changes_list_spec.rb index 69d86144e32..464508fcd73 100644 --- a/spec/lib/gitlab/changes_list_spec.rb +++ b/spec/lib/gitlab/changes_list_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::ChangesList do let(:invalid_changes) { 1 } context 'when changes is a valid string' do - let(:changes_list) { Gitlab::ChangesList.new(valid_changes_string) } + let(:changes_list) { described_class.new(valid_changes_string) } it 'splits elements by newline character' do expect(changes_list).to contain_exactly({ diff --git a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb index abc93e1b44a..3b905611467 100644 --- a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb +++ b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb @@ -135,6 +135,17 @@ describe Gitlab::Ci::Build::Artifacts::Metadata::Entry do subject { |example| path(example).nodes } it { is_expected.to eq 4 } end + + describe '#blob' do + let(:file_entry) { |example| path(example) } + subject { file_entry.blob } + + it 'returns a blob representing the entry data' do + expect(subject).to be_a(Blob) + expect(subject.path).to eq(file_entry.path) + expect(subject.size).to eq(file_entry.metadata[:size]) + end + end end describe 'non-existent/', path: 'non-existent/' do diff --git a/spec/lib/gitlab/ci/build/credentials/factory_spec.rb b/spec/lib/gitlab/ci/build/credentials/factory_spec.rb index 10b4b7a8826..d53db05e5e6 100644 --- a/spec/lib/gitlab/ci/build/credentials/factory_spec.rb +++ b/spec/lib/gitlab/ci/build/credentials/factory_spec.rb @@ -3,14 +3,14 @@ require 'spec_helper' describe Gitlab::Ci::Build::Credentials::Factory do let(:build) { create(:ci_build, name: 'spinach', stage: 'test', stage_idx: 0) } - subject { Gitlab::Ci::Build::Credentials::Factory.new(build).create! } + subject { described_class.new(build).create! } class TestProvider def initialize(build); end end before do - allow_any_instance_of(Gitlab::Ci::Build::Credentials::Factory).to receive(:providers).and_return([TestProvider]) + allow_any_instance_of(described_class).to receive(:providers).and_return([TestProvider]) end context 'when provider is valid' do diff --git a/spec/lib/gitlab/ci/build/credentials/registry_spec.rb b/spec/lib/gitlab/ci/build/credentials/registry_spec.rb index 84e44dd53e2..c6054138cde 100644 --- a/spec/lib/gitlab/ci/build/credentials/registry_spec.rb +++ b/spec/lib/gitlab/ci/build/credentials/registry_spec.rb @@ -4,14 +4,14 @@ describe Gitlab::Ci::Build::Credentials::Registry do let(:build) { create(:ci_build, name: 'spinach', stage: 'test', stage_idx: 0) } let(:registry_url) { 'registry.example.com:5005' } - subject { Gitlab::Ci::Build::Credentials::Registry.new(build) } + subject { described_class.new(build) } before do stub_container_registry_config(host_port: registry_url) end it 'contains valid DockerRegistry credentials' do - expect(subject).to be_kind_of(Gitlab::Ci::Build::Credentials::Registry) + expect(subject).to be_kind_of(described_class) expect(subject.username).to eq 'gitlab-ci-token' expect(subject.password).to eq build.token @@ -20,7 +20,7 @@ describe Gitlab::Ci::Build::Credentials::Registry do end describe '.valid?' do - subject { Gitlab::Ci::Build::Credentials::Registry.new(build).valid? } + subject { described_class.new(build).valid? } context 'when registry is enabled' do before do diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb index 0864bc7258d..809fda11879 100644 --- a/spec/lib/gitlab/ci/cron_parser_spec.rb +++ b/spec/lib/gitlab/ci/cron_parser_spec.rb @@ -60,14 +60,60 @@ describe Gitlab::Ci::CronParser do end end - context 'when cron_timezone is US/Pacific' do - let(:cron) { '0 0 * * *' } - let(:cron_timezone) { 'US/Pacific' } + context 'when cron_timezone is TZInfo format' do + before do + allow(Time).to receive(:zone) + .and_return(ActiveSupport::TimeZone['UTC']) + end - it_behaves_like "returns time in the future" + let(:hour_in_utc) do + ActiveSupport::TimeZone[cron_timezone] + .now.change(hour: 0).in_time_zone('UTC').hour + end + + context 'when cron_timezone is US/Pacific' do + let(:cron) { '* 0 * * *' } + let(:cron_timezone) { 'US/Pacific' } + + it_behaves_like "returns time in the future" + + it 'converts time in server time zone' do + expect(subject.hour).to eq(hour_in_utc) + end + end + end + + context 'when cron_timezone is ActiveSupport::TimeZone format' do + before do + allow(Time).to receive(:zone) + .and_return(ActiveSupport::TimeZone['UTC']) + end + + let(:hour_in_utc) do + ActiveSupport::TimeZone[cron_timezone] + .now.change(hour: 0).in_time_zone('UTC').hour + end + + context 'when cron_timezone is Berlin' do + let(:cron) { '* 0 * * *' } + let(:cron_timezone) { 'Berlin' } + + it_behaves_like "returns time in the future" + + it 'converts time in server time zone' do + expect(subject.hour).to eq(hour_in_utc) + end + end - it 'converts time in server time zone' do - expect(subject.hour).to eq((Time.zone.now.in_time_zone(cron_timezone).utc_offset / 60 / 60).abs) + context 'when cron_timezone is Eastern Time (US & Canada)' do + let(:cron) { '* 0 * * *' } + let(:cron_timezone) { 'Eastern Time (US & Canada)' } + + it_behaves_like "returns time in the future" + + it 'converts time in server time zone' do + expect(subject.hour).to eq(hour_in_utc) + end end end end @@ -76,9 +122,21 @@ describe Gitlab::Ci::CronParser do let(:cron) { 'invalid_cron' } let(:cron_timezone) { 'invalid_cron_timezone' } - it 'returns nil' do - is_expected.to be_nil - end + it { is_expected.to be_nil } + end + + context 'when cron syntax is quoted' do + let(:cron) { "'0 * * * *'" } + let(:cron_timezone) { 'UTC' } + + it { expect(subject).to be_nil } + end + + context 'when cron syntax is rufus-scheduler syntax' do + let(:cron) { 'every 3h' } + let(:cron_timezone) { 'UTC' } + + it { expect(subject).to be_nil } end end @@ -96,6 +154,12 @@ describe Gitlab::Ci::CronParser do it { is_expected.to eq(false) } end + + context 'when cron syntax is quoted' do + let(:cron) { "'0 * * * *'" } + + it { is_expected.to eq(false) } + end end describe '#cron_timezone_valid?' do @@ -112,5 +176,11 @@ describe Gitlab::Ci::CronParser do it { is_expected.to eq(false) } end + + context 'when cron_timezone is ActiveSupport::TimeZone format' do + let(:cron_timezone) { 'Eastern Time (US & Canada)' } + + it { is_expected.to eq(true) } + end end end diff --git a/spec/lib/gitlab/ci/status/group/common_spec.rb b/spec/lib/gitlab/ci/status/group/common_spec.rb new file mode 100644 index 00000000000..c0ca05881f5 --- /dev/null +++ b/spec/lib/gitlab/ci/status/group/common_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Group::Common do + subject do + Gitlab::Ci::Status::Core.new(double, double) + .extend(described_class) + end + + it 'does not have action' do + expect(subject).not_to have_action + end + + it 'has details' do + expect(subject).not_to have_details + end + + it 'has no details_path' do + expect(subject.details_path).to be_falsy + end +end diff --git a/spec/lib/gitlab/ci/status/group/factory_spec.rb b/spec/lib/gitlab/ci/status/group/factory_spec.rb new file mode 100644 index 00000000000..0cd83123938 --- /dev/null +++ b/spec/lib/gitlab/ci/status/group/factory_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Group::Factory do + it 'inherits from the core factory' do + expect(described_class) + .to be < Gitlab::Ci::Status::Factory + end + + it 'exposes group helpers' do + expect(described_class.common_helpers) + .to eq Gitlab::Ci::Status::Group::Common + end +end diff --git a/spec/lib/gitlab/current_settings_spec.rb b/spec/lib/gitlab/current_settings_spec.rb index b01c4805a34..c796c98ec9f 100644 --- a/spec/lib/gitlab/current_settings_spec.rb +++ b/spec/lib/gitlab/current_settings_spec.rb @@ -10,7 +10,7 @@ describe Gitlab::CurrentSettings do describe '#current_application_settings' do context 'with DB available' do before do - allow_any_instance_of(Gitlab::CurrentSettings).to receive(:connect_to_db?).and_return(true) + allow_any_instance_of(described_class).to receive(:connect_to_db?).and_return(true) end it 'attempts to use cached values first' do @@ -36,7 +36,7 @@ describe Gitlab::CurrentSettings do context 'with DB unavailable' do before do - allow_any_instance_of(Gitlab::CurrentSettings).to receive(:connect_to_db?).and_return(false) + allow_any_instance_of(described_class).to receive(:connect_to_db?).and_return(false) end it 'returns an in-memory ApplicationSetting object' do diff --git a/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb index c455cd9b942..d8757c601ab 100644 --- a/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb @@ -20,7 +20,7 @@ describe Gitlab::CycleAnalytics::BaseEventFetcher do before do allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return(Issue.all) - allow_any_instance_of(Gitlab::CycleAnalytics::BaseEventFetcher).to receive(:serialize) do |event| + allow_any_instance_of(described_class).to receive(:serialize) do |event| event end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index a044b871730..737fac14f92 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -726,4 +726,37 @@ describe Gitlab::Database::MigrationHelpers, lib: true do expect(model.column_for(:users, :kittens)).to be_nil end end + + describe '#replace_sql' do + context 'using postgres' do + before do + allow(Gitlab::Database).to receive(:mysql?).and_return(false) + end + + it 'builds the sql with correct functions' do + expect(model.replace_sql(Arel::Table.new(:users)[:first_name], "Alice", "Eve").to_s). + to include('regexp_replace') + end + end + + context 'using mysql' do + before do + allow(Gitlab::Database).to receive(:mysql?).and_return(true) + end + + it 'builds the sql with the correct functions' do + expect(model.replace_sql(Arel::Table.new(:users)[:first_name], "Alice", "Eve").to_s). + to include('locate', 'insert') + end + end + + describe 'results' do + let!(:user) { create(:user, name: 'Kathy Alice Aliceson') } + + it 'replaces the correct part of the string' do + model.update_column_in_batches(:users, :name, model.replace_sql(Arel::Table.new(:users)[:name], 'Alice', 'Eve')) + expect(user.reload.name).to eq('Kathy Eve Aliceson') + end + end + end end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb new file mode 100644 index 00000000000..64bc5fc0429 --- /dev/null +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb @@ -0,0 +1,197 @@ +require 'spec_helper' + +describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase do + let(:migration) { FakeRenameReservedPathMigrationV1.new } + let(:subject) { described_class.new(['the-path'], migration) } + + before do + allow(migration).to receive(:say) + end + + def migration_namespace(namespace) + Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses:: + Namespace.find(namespace.id) + end + + def migration_project(project) + Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses:: + Project.find(project.id) + end + + describe "#remove_last_ocurrence" do + it "removes only the last occurance of a string" do + input = "this/is/a-word-to-replace/namespace/with/a-word-to-replace" + + expect(subject.remove_last_occurrence(input, "a-word-to-replace")) + .to eq("this/is/a-word-to-replace/namespace/with/") + end + end + + describe '#remove_cached_html_for_projects' do + let(:project) { create(:empty_project, description_html: 'Project description') } + + it 'removes description_html from projects' do + subject.remove_cached_html_for_projects([project.id]) + + expect(project.reload.description_html).to be_nil + end + + it 'removes issue descriptions' do + issue = create(:issue, project: project, description_html: 'Issue description') + + subject.remove_cached_html_for_projects([project.id]) + + expect(issue.reload.description_html).to be_nil + end + + it 'removes merge request descriptions' do + merge_request = create(:merge_request, + source_project: project, + target_project: project, + description_html: 'MergeRequest description') + + subject.remove_cached_html_for_projects([project.id]) + + expect(merge_request.reload.description_html).to be_nil + end + + it 'removes note html' do + note = create(:note, + project: project, + noteable: create(:issue, project: project), + note_html: 'note description') + + subject.remove_cached_html_for_projects([project.id]) + + expect(note.reload.note_html).to be_nil + end + + it 'removes milestone description' do + milestone = create(:milestone, + project: project, + description_html: 'milestone description') + + subject.remove_cached_html_for_projects([project.id]) + + expect(milestone.reload.description_html).to be_nil + end + end + + describe '#rename_path_for_routable' do + context 'for namespaces' do + let(:namespace) { create(:namespace, path: 'the-path') } + it "renames namespaces called the-path" do + subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(namespace.reload.path).to eq("the-path0") + end + + it "renames the route to the namespace" do + subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(Namespace.find(namespace.id).full_path).to eq("the-path0") + end + + it "renames the route for projects of the namespace" do + project = create(:project, path: "project-path", namespace: namespace) + + subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(project.route.reload.path).to eq("the-path0/project-path") + end + + it 'returns the old & the new path' do + old_path, new_path = subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(old_path).to eq('the-path') + expect(new_path).to eq('the-path0') + end + + context "the-path namespace -> subgroup -> the-path0 project" do + it "updates the route of the project correctly" do + subgroup = create(:group, path: "subgroup", parent: namespace) + project = create(:project, path: "the-path0", namespace: subgroup) + + subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(project.route.reload.path).to eq("the-path0/subgroup/the-path0") + end + end + end + + context 'for projects' do + let(:parent) { create(:namespace, path: 'the-parent') } + let(:project) { create(:empty_project, path: 'the-path', namespace: parent) } + + it 'renames the project called `the-path`' do + subject.rename_path_for_routable(migration_project(project)) + + expect(project.reload.path).to eq('the-path0') + end + + it 'renames the route for the project' do + subject.rename_path_for_routable(project) + + expect(project.reload.route.path).to eq('the-parent/the-path0') + end + + it 'returns the old & new path' do + old_path, new_path = subject.rename_path_for_routable(migration_project(project)) + + expect(old_path).to eq('the-parent/the-path') + expect(new_path).to eq('the-parent/the-path0') + end + end + end + + describe '#move_pages' do + it 'moves the pages directory' do + expect(subject).to receive(:move_folders) + .with(TestEnv.pages_path, 'old-path', 'new-path') + + subject.move_pages('old-path', 'new-path') + end + end + + describe "#move_uploads" do + let(:test_dir) { File.join(Rails.root, 'tmp', 'tests', 'rename_reserved_paths') } + let(:uploads_dir) { File.join(test_dir, 'public', 'uploads') } + + it 'moves subdirectories in the uploads folder' do + expect(subject).to receive(:uploads_dir).and_return(uploads_dir) + expect(subject).to receive(:move_folders).with(uploads_dir, 'old_path', 'new_path') + + subject.move_uploads('old_path', 'new_path') + end + + it "doesn't move uploads when they are stored in object storage" do + expect(subject).to receive(:file_storage?).and_return(false) + expect(subject).not_to receive(:move_folders) + + subject.move_uploads('old_path', 'new_path') + end + end + + describe '#move_folders' do + let(:test_dir) { File.join(Rails.root, 'tmp', 'tests', 'rename_reserved_paths') } + let(:uploads_dir) { File.join(test_dir, 'public', 'uploads') } + + before do + FileUtils.remove_dir(test_dir) if File.directory?(test_dir) + FileUtils.mkdir_p(uploads_dir) + allow(subject).to receive(:uploads_dir).and_return(uploads_dir) + end + + it 'moves a folder with files' do + source = File.join(uploads_dir, 'parent-group', 'sub-group') + FileUtils.mkdir_p(source) + destination = File.join(uploads_dir, 'parent-group', 'moved-group') + FileUtils.touch(File.join(source, 'test.txt')) + expected_file = File.join(destination, 'test.txt') + + subject.move_folders(uploads_dir, File.join('parent-group', 'sub-group'), File.join('parent-group', 'moved-group')) + + expect(File.exist?(expected_file)).to be(true) + end + end +end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb new file mode 100644 index 00000000000..a25c5da488a --- /dev/null +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb @@ -0,0 +1,171 @@ +require 'spec_helper' + +describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do + let(:migration) { FakeRenameReservedPathMigrationV1.new } + let(:subject) { described_class.new(['the-path'], migration) } + + before do + allow(migration).to receive(:say) + end + + def migration_namespace(namespace) + Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses:: + Namespace.find(namespace.id) + end + + describe '#namespaces_for_paths' do + context 'nested namespaces' do + let(:subject) { described_class.new(['parent/the-Path'], migration) } + + it 'includes the namespace' do + parent = create(:namespace, path: 'parent') + child = create(:namespace, path: 'the-path', parent: parent) + + found_ids = subject.namespaces_for_paths(type: :child). + map(&:id) + expect(found_ids).to contain_exactly(child.id) + end + end + + context 'for child namespaces' do + it 'only returns child namespaces with the correct path' do + _root_namespace = create(:namespace, path: 'THE-path') + _other_path = create(:namespace, + path: 'other', + parent: create(:namespace)) + namespace = create(:namespace, + path: 'the-path', + parent: create(:namespace)) + + found_ids = subject.namespaces_for_paths(type: :child). + map(&:id) + expect(found_ids).to contain_exactly(namespace.id) + end + end + + context 'for top levelnamespaces' do + it 'only returns child namespaces with the correct path' do + root_namespace = create(:namespace, path: 'the-path') + _other_path = create(:namespace, path: 'other') + _child_namespace = create(:namespace, + path: 'the-path', + parent: create(:namespace)) + + found_ids = subject.namespaces_for_paths(type: :top_level). + map(&:id) + expect(found_ids).to contain_exactly(root_namespace.id) + end + end + end + + describe '#move_repositories' do + let(:namespace) { create(:group, name: 'hello-group') } + it 'moves a project for a namespace' do + create(:project, namespace: namespace, path: 'hello-project') + expected_path = File.join(TestEnv.repos_path, 'bye-group', 'hello-project.git') + + subject.move_repositories(namespace, 'hello-group', 'bye-group') + + expect(File.directory?(expected_path)).to be(true) + end + + it 'moves a namespace in a subdirectory correctly' do + child_namespace = create(:group, name: 'sub-group', parent: namespace) + create(:project, namespace: child_namespace, path: 'hello-project') + + expected_path = File.join(TestEnv.repos_path, 'hello-group', 'renamed-sub-group', 'hello-project.git') + + subject.move_repositories(child_namespace, 'hello-group/sub-group', 'hello-group/renamed-sub-group') + + expect(File.directory?(expected_path)).to be(true) + end + + it 'moves a parent namespace with subdirectories' do + child_namespace = create(:group, name: 'sub-group', parent: namespace) + create(:project, namespace: child_namespace, path: 'hello-project') + expected_path = File.join(TestEnv.repos_path, 'renamed-group', 'sub-group', 'hello-project.git') + + subject.move_repositories(child_namespace, 'hello-group', 'renamed-group') + + expect(File.directory?(expected_path)).to be(true) + end + end + + describe "#child_ids_for_parent" do + it "collects child ids for all levels" do + parent = create(:namespace) + first_child = create(:namespace, parent: parent) + second_child = create(:namespace, parent: parent) + third_child = create(:namespace, parent: second_child) + all_ids = [parent.id, first_child.id, second_child.id, third_child.id] + + collected_ids = subject.child_ids_for_parent(parent, ids: [parent.id]) + + expect(collected_ids).to contain_exactly(*all_ids) + end + end + + describe "#rename_namespace" do + let(:namespace) { create(:namespace, path: 'the-path') } + + it 'renames paths & routes for the namespace' do + expect(subject).to receive(:rename_path_for_routable). + with(namespace). + and_call_original + + subject.rename_namespace(namespace) + + expect(namespace.reload.path).to eq('the-path0') + end + + it "moves the the repository for a project in the namespace" do + create(:project, namespace: namespace, path: "the-path-project") + expected_repo = File.join(TestEnv.repos_path, "the-path0", "the-path-project.git") + + subject.rename_namespace(namespace) + + expect(File.directory?(expected_repo)).to be(true) + end + + it "moves the uploads for the namespace" do + expect(subject).to receive(:move_uploads).with("the-path", "the-path0") + + subject.rename_namespace(namespace) + end + + it "moves the pages for the namespace" do + expect(subject).to receive(:move_pages).with("the-path", "the-path0") + + subject.rename_namespace(namespace) + end + + it 'invalidates the markdown cache of related projects' do + project = create(:empty_project, namespace: namespace, path: "the-path-project") + + expect(subject).to receive(:remove_cached_html_for_projects).with([project.id]) + + subject.rename_namespace(namespace) + end + end + + describe '#rename_namespaces' do + let!(:top_level_namespace) { create(:namespace, path: 'the-path') } + let!(:child_namespace) do + create(:namespace, path: 'the-path', parent: create(:namespace)) + end + + it 'renames top level namespaces the namespace' do + expect(subject).to receive(:rename_namespace). + with(migration_namespace(top_level_namespace)) + + subject.rename_namespaces(type: :top_level) + end + + it 'renames child namespaces' do + expect(subject).to receive(:rename_namespace). + with(migration_namespace(child_namespace)) + + subject.rename_namespaces(type: :child) + end + end +end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb new file mode 100644 index 00000000000..59e8de2712d --- /dev/null +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects do + let(:migration) { FakeRenameReservedPathMigrationV1.new } + let(:subject) { described_class.new(['the-path'], migration) } + + before do + allow(migration).to receive(:say) + end + + describe '#projects_for_paths' do + it 'searches using nested paths' do + namespace = create(:namespace, path: 'hello') + project = create(:empty_project, path: 'THE-path', namespace: namespace) + + result_ids = described_class.new(['Hello/the-path'], migration). + projects_for_paths.map(&:id) + + expect(result_ids).to contain_exactly(project.id) + end + + it 'includes the correct projects' do + project = create(:empty_project, path: 'THE-path') + _other_project = create(:empty_project) + + result_ids = subject.projects_for_paths.map(&:id) + + expect(result_ids).to contain_exactly(project.id) + end + end + + describe '#rename_projects' do + let!(:projects) { create_list(:empty_project, 2, path: 'the-path') } + + it 'renames each project' do + expect(subject).to receive(:rename_project).twice + + subject.rename_projects + end + + it 'invalidates the markdown cache of related projects' do + expect(subject).to receive(:remove_cached_html_for_projects). + with(projects.map(&:id)) + + subject.rename_projects + end + end + + describe '#rename_project' do + let(:project) do + create(:empty_project, + path: 'the-path', + namespace: create(:namespace, path: 'known-parent' )) + end + + it 'renames path & route for the project' do + expect(subject).to receive(:rename_path_for_routable). + with(project). + and_call_original + + subject.rename_project(project) + + expect(project.reload.path).to eq('the-path0') + end + + it 'moves the wiki & the repo' do + expect(subject).to receive(:move_repository). + with(project, 'known-parent/the-path.wiki', 'known-parent/the-path0.wiki') + expect(subject).to receive(:move_repository). + with(project, 'known-parent/the-path', 'known-parent/the-path0') + + subject.rename_project(project) + end + + it 'moves uploads' do + expect(subject).to receive(:move_uploads). + with('known-parent/the-path', 'known-parent/the-path0') + + subject.rename_project(project) + end + + it 'moves pages' do + expect(subject).to receive(:move_pages). + with('known-parent/the-path', 'known-parent/the-path0') + + subject.rename_project(project) + end + end + + describe '#move_repository' do + let(:known_parent) { create(:namespace, path: 'known-parent') } + let(:project) { create(:project, path: 'the-path', namespace: known_parent) } + + it 'moves the repository for a project' do + expected_path = File.join(TestEnv.repos_path, 'known-parent', 'new-repo.git') + + subject.move_repository(project, 'known-parent/the-path', 'known-parent/new-repo') + + expect(File.directory?(expected_path)).to be(true) + end + end +end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb new file mode 100644 index 00000000000..f8cc1eb91ec --- /dev/null +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +shared_examples 'renames child namespaces' do |type| + it 'renames namespaces' do + rename_namespaces = double + expect(described_class::RenameNamespaces). + to receive(:new).with(['first-path', 'second-path'], subject). + and_return(rename_namespaces) + expect(rename_namespaces).to receive(:rename_namespaces). + with(type: :child) + + subject.rename_wildcard_paths(['first-path', 'second-path']) + end +end + +describe Gitlab::Database::RenameReservedPathsMigration::V1 do + let(:subject) { FakeRenameReservedPathMigrationV1.new } + + before do + allow(subject).to receive(:say) + end + + describe '#rename_child_paths' do + it_behaves_like 'renames child namespaces' + end + + describe '#rename_wildcard_paths' do + it_behaves_like 'renames child namespaces' + + it 'should rename projects' do + rename_projects = double + expect(described_class::RenameProjects). + to receive(:new).with(['the-path'], subject). + and_return(rename_projects) + + expect(rename_projects).to receive(:rename_projects) + + subject.rename_wildcard_paths(['the-path']) + end + end + + describe '#rename_root_paths' do + it 'should rename namespaces' do + rename_namespaces = double + expect(described_class::RenameNamespaces). + to receive(:new).with(['the-path'], subject). + and_return(rename_namespaces) + expect(rename_namespaces).to receive(:rename_namespaces). + with(type: :top_level) + + subject.rename_root_paths('the-path') + end + end +end diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb index f127e45ae6a..c6e3524f743 100644 --- a/spec/lib/gitlab/email/receiver_spec.rb +++ b/spec/lib/gitlab/email/receiver_spec.rb @@ -4,6 +4,24 @@ require_relative 'email_shared_blocks' describe Gitlab::Email::Receiver, lib: true do include_context :email_shared_context + context "when the email contains a valid email address in a Delivered-To header" do + let(:email_raw) { fixture_file('emails/forwarded_new_issue.eml') } + let(:handler) { double(:handler) } + + before do + stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.adventuretime.ooo") + + allow(handler).to receive(:execute) + allow(handler).to receive(:metrics_params) + end + + it "finds the mail key" do + expect(Gitlab::Email::Handler).to receive(:for).with(an_instance_of(Mail::Message), 'gitlabhq/gitlabhq+auth_token').and_return(handler) + + receiver.execute + end + end + context "when we cannot find a capable handler" do let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "!!!") } diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 1b78910fa3c..fea186fd4f4 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -24,21 +24,26 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - # TODO: Uncomment when feature is reenabled - # context 'with gitaly enabled' do - # before { stub_gitaly } - # - # it 'gets the branch name from GitalyClient' do - # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name) - # repository.root_ref - # end - # - # it 'wraps GRPC exceptions' do - # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name). - # and_raise(GRPC::Unknown) - # expect { repository.root_ref }.to raise_error(Gitlab::Git::CommandError) - # end - # end + context 'with gitaly enabled' do + before { stub_gitaly } + + it 'gets the branch name from GitalyClient' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name) + repository.root_ref + end + + it 'wraps GRPC not found' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name). + and_raise(GRPC::NotFound) + expect { repository.root_ref }.to raise_error(Gitlab::Git::Repository::NoRepository) + end + + it 'wraps GRPC exceptions' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name). + and_raise(GRPC::Unknown) + expect { repository.root_ref }.to raise_error(Gitlab::Git::CommandError) + end + end end describe "#rugged" do @@ -113,21 +118,26 @@ describe Gitlab::Git::Repository, seed_helper: true do it { is_expected.to include("master") } it { is_expected.not_to include("branch-from-space") } - # TODO: Uncomment when feature is reenabled - # context 'with gitaly enabled' do - # before { stub_gitaly } - # - # it 'gets the branch names from GitalyClient' do - # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names) - # subject - # end - # - # it 'wraps GRPC exceptions' do - # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names). - # and_raise(GRPC::Unknown) - # expect { subject }.to raise_error(Gitlab::Git::CommandError) - # end - # end + context 'with gitaly enabled' do + before { stub_gitaly } + + it 'gets the branch names from GitalyClient' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names) + subject + end + + it 'wraps GRPC not found' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names). + and_raise(GRPC::NotFound) + expect { subject }.to raise_error(Gitlab::Git::Repository::NoRepository) + end + + it 'wraps GRPC other exceptions' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names). + and_raise(GRPC::Unknown) + expect { subject }.to raise_error(Gitlab::Git::CommandError) + end + end end describe '#tag_names' do @@ -145,21 +155,26 @@ describe Gitlab::Git::Repository, seed_helper: true do it { is_expected.to include("v1.0.0") } it { is_expected.not_to include("v5.0.0") } - # TODO: Uncomment when feature is reenabled - # context 'with gitaly enabled' do - # before { stub_gitaly } - # - # it 'gets the tag names from GitalyClient' do - # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names) - # subject - # end - # - # it 'wraps GRPC exceptions' do - # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names). - # and_raise(GRPC::Unknown) - # expect { subject }.to raise_error(Gitlab::Git::CommandError) - # end - # end + context 'with gitaly enabled' do + before { stub_gitaly } + + it 'gets the tag names from GitalyClient' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names) + subject + end + + it 'wraps GRPC not found' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names). + and_raise(GRPC::NotFound) + expect { subject }.to raise_error(Gitlab::Git::Repository::NoRepository) + end + + it 'wraps GRPC exceptions' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names). + and_raise(GRPC::Unknown) + expect { subject }.to raise_error(Gitlab::Git::CommandError) + end + end end shared_examples 'archive check' do |extenstion| @@ -1047,7 +1062,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end it "allows ordering by date" do - expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE) + expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE | Rugged::SORT_TOPO) repository.find_commits(order: :date) end diff --git a/spec/lib/gitlab/git/util_spec.rb b/spec/lib/gitlab/git/util_spec.rb index bcca4d4c746..69d3ca55397 100644 --- a/spec/lib/gitlab/git/util_spec.rb +++ b/spec/lib/gitlab/git/util_spec.rb @@ -9,7 +9,7 @@ describe Gitlab::Git::Util do ["foo\n\n", 2], ].each do |string, line_count| it "counts #{line_count} lines in #{string.inspect}" do - expect(Gitlab::Git::Util.count_lines(string)).to eq(line_count) + expect(described_class.count_lines(string)).to eq(line_count) end end end diff --git a/spec/lib/gitlab/gitaly_client/ref_spec.rb b/spec/lib/gitlab/gitaly_client/ref_spec.rb index 5405eafd281..255f23e6270 100644 --- a/spec/lib/gitlab/gitaly_client/ref_spec.rb +++ b/spec/lib/gitlab/gitaly_client/ref_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::GitalyClient::Ref do let(:project) { create(:empty_project) } let(:repo_path) { project.repository.path_to_repo } - let(:client) { Gitlab::GitalyClient::Ref.new(project.repository) } + let(:client) { described_class.new(project.repository) } before do allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true) diff --git a/spec/lib/gitlab/github_import/issue_formatter_spec.rb b/spec/lib/gitlab/github_import/issue_formatter_spec.rb index f34d09f2c1d..a4089592cf2 100644 --- a/spec/lib/gitlab/github_import/issue_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/issue_formatter_spec.rb @@ -43,7 +43,7 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do description: "*Created by: octocat*\n\nI'm having a problem with this.", state: 'opened', author_id: project.creator_id, - assignee_id: nil, + assignee_ids: [], created_at: created_at, updated_at: updated_at } @@ -64,7 +64,7 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do description: "*Created by: octocat*\n\nI'm having a problem with this.", state: 'closed', author_id: project.creator_id, - assignee_id: nil, + assignee_ids: [], created_at: created_at, updated_at: updated_at } @@ -77,19 +77,19 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do let(:raw_data) { double(base_data.merge(assignee: octocat)) } it 'returns nil as assignee_id when is not a GitLab user' do - expect(issue.attributes.fetch(:assignee_id)).to be_nil + expect(issue.attributes.fetch(:assignee_ids)).to be_empty end it 'returns GitLab user id associated with GitHub id as assignee_id' do gl_user = create(:omniauth_user, extern_uid: octocat.id, provider: 'github') - expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id + expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id] end it 'returns GitLab user id associated with GitHub email as assignee_id' do gl_user = create(:user, email: octocat.email) - expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id + expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id] end end diff --git a/spec/lib/gitlab/gl_repository_spec.rb b/spec/lib/gitlab/gl_repository_spec.rb new file mode 100644 index 00000000000..ac3558ab386 --- /dev/null +++ b/spec/lib/gitlab/gl_repository_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe ::Gitlab::GlRepository do + describe '.parse' do + set(:project) { create(:project) } + + it 'parses a project gl_repository' do + expect(described_class.parse("project-#{project.id}")).to eq([project, false]) + end + + it 'parses a wiki gl_repository' do + expect(described_class.parse("wiki-#{project.id}")).to eq([project, true]) + end + + it 'throws an argument error on an invalid gl_repository' do + expect { described_class.parse("badformat-#{project.id}") }.to raise_error(ArgumentError) + end + end +end diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index ccaa88a5c79..622a0f513f4 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -49,7 +49,7 @@ describe Gitlab::GoogleCodeImport::Importer, lib: true do expect(issue).not_to be_nil expect(issue.iid).to eq(169) expect(issue.author).to eq(project.creator) - expect(issue.assignee).to eq(mapped_user) + expect(issue.assignees).to eq([mapped_user]) expect(issue.state).to eq("closed") expect(issue.label_names).to include("Priority: Medium") expect(issue.label_names).to include("Status: Fixed") diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb index 4cd8cf313a5..45ccd3d6459 100644 --- a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb +++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb @@ -82,9 +82,9 @@ describe Gitlab::HealthChecks::FsShardsCheck do it { is_expected.to include(metric_class.new(:filesystem_readable, 0, shard: :default)) } it { is_expected.to include(metric_class.new(:filesystem_writable, 0, shard: :default)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } end context 'storage points to directory that has both read and write rights' do @@ -96,9 +96,9 @@ describe Gitlab::HealthChecks::FsShardsCheck do it { is_expected.to include(metric_class.new(:filesystem_readable, 1, shard: :default)) } it { is_expected.to include(metric_class.new(:filesystem_writable, 1, shard: :default)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } end end end diff --git a/spec/lib/gitlab/health_checks/simple_check_shared.rb b/spec/lib/gitlab/health_checks/simple_check_shared.rb index 1fa6d0faef9..3f871d66034 100644 --- a/spec/lib/gitlab/health_checks/simple_check_shared.rb +++ b/spec/lib/gitlab/health_checks/simple_check_shared.rb @@ -8,7 +8,7 @@ shared_context 'simple_check' do |metrics_prefix, check_name, success_result| it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 1)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end context 'Check is misbehaving' do @@ -18,7 +18,7 @@ shared_context 'simple_check' do |metrics_prefix, check_name, success_result| it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end context 'Check is timeouting' do @@ -28,7 +28,7 @@ shared_context 'simple_check' do |metrics_prefix, check_name, success_result| it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 1)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end end diff --git a/spec/lib/gitlab/i18n_spec.rb b/spec/lib/gitlab/i18n_spec.rb new file mode 100644 index 00000000000..52f2614d5ca --- /dev/null +++ b/spec/lib/gitlab/i18n_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +module Gitlab + describe I18n, lib: true do + let(:user) { create(:user, preferred_language: 'es') } + + describe '.set_locale' do + it 'sets the locale based on current user preferred language' do + Gitlab::I18n.set_locale(user) + + expect(FastGettext.locale).to eq('es') + expect(::I18n.locale).to eq(:es) + end + end + + describe '.reset_locale' do + it 'resets the locale to the default language' do + Gitlab::I18n.set_locale(user) + + Gitlab::I18n.reset_locale + + expect(FastGettext.locale).to eq('en') + expect(::I18n.locale).to eq(:en) + end + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 0abf89d060c..baa81870e81 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -3,12 +3,13 @@ issues: - subscriptions - award_emoji - author -- assignee +- assignees - updated_by - milestone - notes - label_links - labels +- last_edited_by - todos - user_agent_detail - moved_to @@ -16,6 +17,7 @@ issues: - merge_requests_closing_issues - metrics - timelogs +- issue_assignees events: - author - project @@ -26,6 +28,7 @@ notes: - noteable - author - updated_by +- last_edited_by - resolved_by - todos - events @@ -71,6 +74,7 @@ merge_requests: - notes - label_links - labels +- last_edited_by - todos - target_project - source_project @@ -225,6 +229,7 @@ project: - authorized_users - project_authorizations - route +- redirect_routes - statistics - container_repositories - uploads diff --git a/spec/lib/gitlab/import_export/fork_spec.rb b/spec/lib/gitlab/import_export/fork_spec.rb index c5ce06afd73..42f3fc59f04 100644 --- a/spec/lib/gitlab/import_export/fork_spec.rb +++ b/spec/lib/gitlab/import_export/fork_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'forked project import', services: true do let(:user) { create(:user) } let!(:project_with_repo) { create(:project, :test_repo, name: 'test-repo-restorer', path: 'test-repo-restorer') } - let!(:project) { create(:empty_project) } + let!(:project) { create(:empty_project, name: 'test-repo-restorer-no-repo', path: 'test-repo-restorer-no-repo') } let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) } let(:forked_from_project) { create(:project) } diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index bfecfa28ed1..fdbb6a0556d 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -2,6 +2,7 @@ "description": "Nisi et repellendus ut enim quo accusamus vel magnam.", "visibility_level": 10, "archived": false, + "description_html": "description", "labels": [ { "id": 2, diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 0e9607c5bd3..14338515892 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -30,6 +30,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED) end + it 'has the project html description' do + expect(Project.find_by_path('project').description_html).to eq('description') + end + it 'has the same label associated to two issues' do expect(ProjectLabel.find_by_title('test2').issues.count).to eq(2) end diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index d2d89e3b019..5aeb29b7fec 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do let(:project_tree_saver) { described_class.new(project: project, current_user: user, shared: shared) } let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:user) { create(:user) } - let(:project) { setup_project } + let!(:project) { setup_project } before do project.team << [user, :master] @@ -189,11 +189,21 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do end end end + + context 'project attributes' do + it 'contains the html description' do + expect(saved_project_json).to include("description_html" => 'description') + end + + it 'does not contain the runners token' do + expect(saved_project_json).not_to include("runners_token" => 'token') + end + end end end def setup_project - issue = create(:issue, assignee: user) + issue = create(:issue, assignees: [user]) snippet = create(:project_snippet) release = create(:release) group = create(:group) @@ -209,6 +219,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do releases: [release], group: group ) + project.update_column(:description_html, 'description') project_label = create(:label, project: project) group_label = create(:group_label, group: group) create(:label_link, label: project_label, target: issue) diff --git a/spec/lib/gitlab/import_export/reader_spec.rb b/spec/lib/gitlab/import_export/reader_spec.rb index 48d74b07e27..d700af142be 100644 --- a/spec/lib/gitlab/import_export/reader_spec.rb +++ b/spec/lib/gitlab/import_export/reader_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::ImportExport::Reader, lib: true do let(:test_config) { 'spec/support/import_export/import_export.yml' } let(:project_tree_hash) do { - only: [:name, :path], + except: [:id, :created_at], include: [:issues, :labels, { merge_requests: { only: [:id], diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 0372e3f7dbf..a66086f8b47 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -23,6 +23,8 @@ Issue: - weight - time_estimate - relative_position +- last_edited_at +- last_edited_by_id Event: - id - target_type @@ -154,6 +156,8 @@ MergeRequest: - approvals_before_merge - rebase_commit_sha - time_estimate +- last_edited_at +- last_edited_by_id MergeRequestDiff: - id - state @@ -329,6 +333,29 @@ Project: - snippets_enabled - visibility_level - archived +- created_at +- updated_at +- last_activity_at +- star_count +- ci_id +- shared_runners_enabled +- build_coverage_regex +- build_allow_git_fetchs +- build_timeout +- pending_delete +- public_builds +- last_repository_check_failed +- last_repository_check_at +- container_registry_enabled +- only_allow_merge_if_pipeline_succeeds +- has_external_issue_tracker +- request_access_enabled +- has_external_wiki +- only_allow_merge_if_all_discussions_are_resolved +- auto_cancel_pending_pipelines +- printing_merge_request_link_enabled +- build_allow_git_fetch +- last_repository_updated_at Author: - name ProjectFeature: diff --git a/spec/lib/gitlab/ldap/person_spec.rb b/spec/lib/gitlab/ldap/person_spec.rb index 9a556cde5d5..087c4d8c92c 100644 --- a/spec/lib/gitlab/ldap/person_spec.rb +++ b/spec/lib/gitlab/ldap/person_spec.rb @@ -20,7 +20,7 @@ describe Gitlab::LDAP::Person do it 'uses the configured name attribute and handles values as an array' do name = 'John Doe' entry['cn'] = [name] - person = Gitlab::LDAP::Person.new(entry, 'ldapmain') + person = described_class.new(entry, 'ldapmain') expect(person.name).to eq(name) end @@ -30,7 +30,7 @@ describe Gitlab::LDAP::Person do it 'returns the value of mail, if present' do mail = 'john@example.com' entry['mail'] = mail - person = Gitlab::LDAP::Person.new(entry, 'ldapmain') + person = described_class.new(entry, 'ldapmain') expect(person.email).to eq([mail]) end @@ -38,7 +38,7 @@ describe Gitlab::LDAP::Person do it 'returns the value of userPrincipalName, if mail and email are not present' do user_principal_name = 'john.doe@example.com' entry['userPrincipalName'] = user_principal_name - person = Gitlab::LDAP::Person.new(entry, 'ldapmain') + person = described_class.new(entry, 'ldapmain') expect(person.email).to eq([user_principal_name]) end diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb index ab6e311b1e8..208a8d028cd 100644 --- a/spec/lib/gitlab/metrics_spec.rb +++ b/spec/lib/gitlab/metrics_spec.rb @@ -20,7 +20,7 @@ describe Gitlab::Metrics do expect(pool).to receive(:with).and_yield(connection) expect(connection).to receive(:write_points).with(an_instance_of(Array)) - expect(Gitlab::Metrics).to receive(:pool).and_return(pool) + expect(described_class).to receive(:pool).and_return(pool) described_class.submit_metrics([{ 'series' => 'kittens', 'tags' => {} }]) end @@ -64,7 +64,7 @@ describe Gitlab::Metrics do describe '.measure' do context 'without a transaction' do it 'returns the return value of the block' do - val = Gitlab::Metrics.measure(:foo) { 10 } + val = described_class.measure(:foo) { 10 } expect(val).to eq(10) end @@ -74,7 +74,7 @@ describe Gitlab::Metrics do let(:transaction) { Gitlab::Metrics::Transaction.new } before do - allow(Gitlab::Metrics).to receive(:current_transaction). + allow(described_class).to receive(:current_transaction). and_return(transaction) end @@ -88,11 +88,11 @@ describe Gitlab::Metrics do expect(transaction).to receive(:increment). with('foo_call_count', 1) - Gitlab::Metrics.measure(:foo) { 10 } + described_class.measure(:foo) { 10 } end it 'returns the return value of the block' do - val = Gitlab::Metrics.measure(:foo) { 10 } + val = described_class.measure(:foo) { 10 } expect(val).to eq(10) end @@ -105,7 +105,7 @@ describe Gitlab::Metrics do expect_any_instance_of(Gitlab::Metrics::Transaction). not_to receive(:add_tag) - Gitlab::Metrics.tag_transaction(:foo, 'bar') + described_class.tag_transaction(:foo, 'bar') end end @@ -113,13 +113,13 @@ describe Gitlab::Metrics do let(:transaction) { Gitlab::Metrics::Transaction.new } it 'adds the tag to the transaction' do - expect(Gitlab::Metrics).to receive(:current_transaction). + expect(described_class).to receive(:current_transaction). and_return(transaction) expect(transaction).to receive(:add_tag). with(:foo, 'bar') - Gitlab::Metrics.tag_transaction(:foo, 'bar') + described_class.tag_transaction(:foo, 'bar') end end end @@ -130,7 +130,7 @@ describe Gitlab::Metrics do expect_any_instance_of(Gitlab::Metrics::Transaction). not_to receive(:action=) - Gitlab::Metrics.action = 'foo' + described_class.action = 'foo' end end @@ -138,12 +138,12 @@ describe Gitlab::Metrics do it 'sets the action of a transaction' do trans = Gitlab::Metrics::Transaction.new - expect(Gitlab::Metrics).to receive(:current_transaction). + expect(described_class).to receive(:current_transaction). and_return(trans) expect(trans).to receive(:action=).with('foo') - Gitlab::Metrics.action = 'foo' + described_class.action = 'foo' end end end @@ -160,7 +160,7 @@ describe Gitlab::Metrics do expect_any_instance_of(Gitlab::Metrics::Transaction). not_to receive(:add_event) - Gitlab::Metrics.add_event(:meow) + described_class.add_event(:meow) end end @@ -170,10 +170,10 @@ describe Gitlab::Metrics do expect(transaction).to receive(:add_event).with(:meow) - expect(Gitlab::Metrics).to receive(:current_transaction). + expect(described_class).to receive(:current_transaction). and_return(transaction) - Gitlab::Metrics.add_event(:meow) + described_class.add_event(:meow) end end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index e0ebea63eb4..a7c8e7f1f57 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -89,7 +89,7 @@ describe Gitlab::ProjectSearchResults, lib: true do let(:project) { create(:empty_project, :internal) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } it 'does not list project confidential issues for non project members' do results = described_class.new(non_member, project, query) diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb index 280264188e2..fc453a2704b 100644 --- a/spec/lib/gitlab/prometheus_spec.rb +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -49,6 +49,36 @@ describe Gitlab::Prometheus, lib: true do end end + describe 'failure to reach a provided prometheus url' do + let(:prometheus_url) {"https://prometheus.invalid.example.com"} + + context 'exceptions are raised' do + it 'raises a Gitlab::PrometheusError error when a SocketError is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, SocketError) + + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "Can't connect to #{prometheus_url}") + expect(req_stub).to have_been_requested + end + + it 'raises a Gitlab::PrometheusError error when a SSLError is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, OpenSSL::SSL::SSLError) + + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "#{prometheus_url} contains invalid SSL data") + expect(req_stub).to have_been_requested + end + + it 'raises a Gitlab::PrometheusError error when a HTTParty::Error is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, HTTParty::Error) + + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "Network connection error") + expect(req_stub).to have_been_requested + end + end + end + describe '#query' do let(:prometheus_query) { prometheus_cpu_query('env-slug') } let(:query_url) { prometheus_query_url(prometheus_query) } diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index 127cd8c78d8..72e947f2cc2 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -45,8 +45,8 @@ describe Gitlab::Regex, lib: true do it { is_expected.not_to match('foo-') } end - describe 'FULL_NAMESPACE_REGEX_STR' do - subject { %r{\A#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR}\z} } + describe '.full_namespace_regex' do + subject { described_class.full_namespace_regex } it { is_expected.to match('gitlab.org') } it { is_expected.to match('gitlab.org/gitlab-git') } diff --git a/spec/lib/gitlab/repo_path_spec.rb b/spec/lib/gitlab/repo_path_spec.rb index 0fb5d7646f2..f94c9c2e315 100644 --- a/spec/lib/gitlab/repo_path_spec.rb +++ b/spec/lib/gitlab/repo_path_spec.rb @@ -1,6 +1,30 @@ require 'spec_helper' describe ::Gitlab::RepoPath do + describe '.parse' do + set(:project) { create(:project) } + + it 'parses a full repository path' do + expect(described_class.parse(project.repository.path)).to eq([project, false]) + end + + it 'parses a full wiki path' do + expect(described_class.parse(project.wiki.repository.path)).to eq([project, true]) + end + + it 'parses a relative repository path' do + expect(described_class.parse(project.full_path + '.git')).to eq([project, false]) + end + + it 'parses a relative wiki path' do + expect(described_class.parse(project.full_path + '.wiki.git')).to eq([project, true]) + end + + it 'parses a relative path starting with /' do + expect(described_class.parse('/' + project.full_path + '.git')).to eq([project, false]) + end + end + describe '.strip_storage_path' do before do allow(Gitlab.config.repositories).to receive(:storages).and_return({ diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 847fb977400..31c3cd4d53c 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -72,9 +72,9 @@ describe Gitlab::SearchResults do let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project_1, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project_1, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignees: [assignee]) } let!(:security_issue_3) { create(:issue, :confidential, project: project_2, title: 'Security issue 3', author: author) } - let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee) } + let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignees: [assignee]) } let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') } it 'does not list confidential issues for non project members' do diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index 6675d26734e..a97a0f8452b 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -91,4 +91,45 @@ describe Gitlab::Shell, lib: true do end end end + + describe 'projects commands' do + let(:projects_path) { 'tmp/tests/shell-projects-test/bin/gitlab-projects' } + + before do + allow(Gitlab.config.gitlab_shell).to receive(:path).and_return('tmp/tests/shell-projects-test') + allow(Gitlab.config.gitlab_shell).to receive(:git_timeout).and_return(800) + end + + describe '#fetch_remote' do + it 'returns true when the command succeeds' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'fetch-remote', 'current/storage', 'project/path.git', 'new/storage', '800']).and_return([nil, 0]) + + expect(gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage')).to be true + end + + it 'raises an exception when the command fails' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'fetch-remote', 'current/storage', 'project/path.git', 'new/storage', '800']).and_return(["error", 1]) + + expect { gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage') }.to raise_error(Gitlab::Shell::Error, "error") + end + end + + describe '#import_repository' do + it 'returns true when the command succeeds' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'import-project', 'current/storage', 'project/path.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', "800"]).and_return([nil, 0]) + + expect(gitlab_shell.import_repository('current/storage', 'project/path', 'https://gitlab.com/gitlab-org/gitlab-ce.git')).to be true + end + + it 'raises an exception when the command fails' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'import-project', 'current/storage', 'project/path.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', "800"]).and_return(["error", 1]) + + expect { gitlab_shell.import_repository('current/storage', 'project/path', 'https://gitlab.com/gitlab-org/gitlab-ce.git') }.to raise_error(Gitlab::Shell::Error, "error") + end + end + end end diff --git a/spec/lib/gitlab/sidekiq_throttler_spec.rb b/spec/lib/gitlab/sidekiq_throttler_spec.rb index ff32e0e699d..6374ac80207 100644 --- a/spec/lib/gitlab/sidekiq_throttler_spec.rb +++ b/spec/lib/gitlab/sidekiq_throttler_spec.rb @@ -13,14 +13,14 @@ describe Gitlab::SidekiqThrottler do describe '#execute!' do it 'sets limits on the selected queues' do - Gitlab::SidekiqThrottler.execute! + described_class.execute! expect(Sidekiq::Queue['build'].limit).to eq 4 expect(Sidekiq::Queue['project_cache'].limit).to eq 4 end it 'does not set limits on other queues' do - Gitlab::SidekiqThrottler.execute! + described_class.execute! expect(Sidekiq::Queue['merge'].limit).to be_nil end diff --git a/spec/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb index c9c2f314e57..5b9173d3d3f 100644 --- a/spec/lib/gitlab/slash_commands/command_definition_spec.rb +++ b/spec/lib/gitlab/slash_commands/command_definition_spec.rb @@ -167,6 +167,58 @@ describe Gitlab::SlashCommands::CommandDefinition do end end end + + context 'when the command defines parse_params block' do + before do + subject.parse_params_block = ->(raw) { raw.strip } + subject.action_block = ->(parsed) { self.received_arg = parsed } + end + + it 'executes the command passing the parsed param' do + subject.execute(context, {}, 'something ') + + expect(context.received_arg).to eq('something') + end + end + end + end + end + + describe '#explain' do + context 'when the command is not available' do + before do + subject.condition_block = proc { false } + subject.explanation = 'Explanation' + end + + it 'returns nil' do + result = subject.explain({}, {}, nil) + + expect(result).to be_nil + end + end + + context 'when the explanation is a static string' do + before do + subject.explanation = 'Explanation' + end + + it 'returns this static string' do + result = subject.explain({}, {}, nil) + + expect(result).to eq 'Explanation' + end + end + + context 'when the explanation is dynamic' do + before do + subject.explanation = proc { |arg| "Dynamic #{arg}" } + end + + it 'invokes the proc' do + result = subject.explain({}, {}, 'explanation') + + expect(result).to eq 'Dynamic explanation' end end end diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 26217a0e3b2..33b49a5ddf9 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::SlashCommands::Dsl do before :all do DummyClass = Struct.new(:project) do - include Gitlab::SlashCommands::Dsl + include Gitlab::SlashCommands::Dsl # rubocop:disable RSpec/DescribedClass desc 'A command with no args' command :no_args, :none do @@ -11,67 +11,99 @@ describe Gitlab::SlashCommands::Dsl do end params 'The first argument' - command :one_arg, :once, :first do |arg1| - arg1 + explanation 'Static explanation' + command :explanation_with_aliases, :once, :first do |arg| + arg end desc do "A dynamic description for #{noteable.upcase}" end params 'The first argument', 'The second argument' - command :two_args do |arg1, arg2| - [arg1, arg2] + command :dynamic_description do |args| + args.split end command :cc + explanation do |arg| + "Action does something with #{arg}" + end condition do project == 'foo' end command :cond_action do |arg| arg end + + parse_params do |raw_arg| + raw_arg.strip + end + command :with_params_parsing do |parsed| + parsed + end end end describe '.command_definitions' do it 'returns an array with commands definitions' do - no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def = DummyClass.command_definitions + no_args_def, explanation_with_aliases_def, dynamic_description_def, + cc_def, cond_action_def, with_params_parsing_def = + DummyClass.command_definitions expect(no_args_def.name).to eq(:no_args) expect(no_args_def.aliases).to eq([:none]) expect(no_args_def.description).to eq('A command with no args') + expect(no_args_def.explanation).to eq('') expect(no_args_def.params).to eq([]) expect(no_args_def.condition_block).to be_nil expect(no_args_def.action_block).to be_a_kind_of(Proc) + expect(no_args_def.parse_params_block).to be_nil - expect(one_arg_def.name).to eq(:one_arg) - expect(one_arg_def.aliases).to eq([:once, :first]) - expect(one_arg_def.description).to eq('') - expect(one_arg_def.params).to eq(['The first argument']) - expect(one_arg_def.condition_block).to be_nil - expect(one_arg_def.action_block).to be_a_kind_of(Proc) + expect(explanation_with_aliases_def.name).to eq(:explanation_with_aliases) + expect(explanation_with_aliases_def.aliases).to eq([:once, :first]) + expect(explanation_with_aliases_def.description).to eq('') + expect(explanation_with_aliases_def.explanation).to eq('Static explanation') + expect(explanation_with_aliases_def.params).to eq(['The first argument']) + expect(explanation_with_aliases_def.condition_block).to be_nil + expect(explanation_with_aliases_def.action_block).to be_a_kind_of(Proc) + expect(explanation_with_aliases_def.parse_params_block).to be_nil - expect(two_args_def.name).to eq(:two_args) - expect(two_args_def.aliases).to eq([]) - expect(two_args_def.to_h(noteable: "issue")[:description]).to eq('A dynamic description for ISSUE') - expect(two_args_def.params).to eq(['The first argument', 'The second argument']) - expect(two_args_def.condition_block).to be_nil - expect(two_args_def.action_block).to be_a_kind_of(Proc) + expect(dynamic_description_def.name).to eq(:dynamic_description) + expect(dynamic_description_def.aliases).to eq([]) + expect(dynamic_description_def.to_h(noteable: 'issue')[:description]).to eq('A dynamic description for ISSUE') + expect(dynamic_description_def.explanation).to eq('') + expect(dynamic_description_def.params).to eq(['The first argument', 'The second argument']) + expect(dynamic_description_def.condition_block).to be_nil + expect(dynamic_description_def.action_block).to be_a_kind_of(Proc) + expect(dynamic_description_def.parse_params_block).to be_nil expect(cc_def.name).to eq(:cc) expect(cc_def.aliases).to eq([]) expect(cc_def.description).to eq('') + expect(cc_def.explanation).to eq('') expect(cc_def.params).to eq([]) expect(cc_def.condition_block).to be_nil expect(cc_def.action_block).to be_nil + expect(cc_def.parse_params_block).to be_nil expect(cond_action_def.name).to eq(:cond_action) expect(cond_action_def.aliases).to eq([]) expect(cond_action_def.description).to eq('') + expect(cond_action_def.explanation).to be_a_kind_of(Proc) expect(cond_action_def.params).to eq([]) expect(cond_action_def.condition_block).to be_a_kind_of(Proc) expect(cond_action_def.action_block).to be_a_kind_of(Proc) + expect(cond_action_def.parse_params_block).to be_nil + + expect(with_params_parsing_def.name).to eq(:with_params_parsing) + expect(with_params_parsing_def.aliases).to eq([]) + expect(with_params_parsing_def.description).to eq('') + expect(with_params_parsing_def.explanation).to eq('') + expect(with_params_parsing_def.params).to eq([]) + expect(with_params_parsing_def.condition_block).to be_nil + expect(with_params_parsing_def.action_block).to be_a_kind_of(Proc) + expect(with_params_parsing_def.parse_params_block).to be_a_kind_of(Proc) end end end diff --git a/spec/lib/gitlab/template/gitignore_template_spec.rb b/spec/lib/gitlab/template/gitignore_template_spec.rb index 9750a012e22..97797f42aaa 100644 --- a/spec/lib/gitlab/template/gitignore_template_spec.rb +++ b/spec/lib/gitlab/template/gitignore_template_spec.rb @@ -24,7 +24,7 @@ describe Gitlab::Template::GitignoreTemplate do it 'returns the Gitignore object of a valid file' do ruby = subject.find('Ruby') - expect(ruby).to be_a Gitlab::Template::GitignoreTemplate + expect(ruby).to be_a described_class expect(ruby.name).to eq('Ruby') end end diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb index e3b8321eda3..6541326d1de 100644 --- a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb +++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb @@ -25,7 +25,7 @@ describe Gitlab::Template::GitlabCiYmlTemplate do it 'returns the GitlabCiYml object of a valid file' do ruby = subject.find('Ruby') - expect(ruby).to be_a Gitlab::Template::GitlabCiYmlTemplate + expect(ruby).to be_a described_class expect(ruby.name).to eq('Ruby') end end diff --git a/spec/lib/gitlab/template/issue_template_spec.rb b/spec/lib/gitlab/template/issue_template_spec.rb index 9213ced7b19..329d1d74970 100644 --- a/spec/lib/gitlab/template/issue_template_spec.rb +++ b/spec/lib/gitlab/template/issue_template_spec.rb @@ -37,7 +37,7 @@ describe Gitlab::Template::IssueTemplate do it 'returns the issue object of a valid file' do ruby = subject.find('bug', project) - expect(ruby).to be_a Gitlab::Template::IssueTemplate + expect(ruby).to be_a described_class expect(ruby.name).to eq('bug') end end diff --git a/spec/lib/gitlab/template/merge_request_template_spec.rb b/spec/lib/gitlab/template/merge_request_template_spec.rb index 77dd3079e22..2b0056d9bab 100644 --- a/spec/lib/gitlab/template/merge_request_template_spec.rb +++ b/spec/lib/gitlab/template/merge_request_template_spec.rb @@ -37,7 +37,7 @@ describe Gitlab::Template::MergeRequestTemplate do it 'returns the merge request object of a valid file' do ruby = subject.find('bug', project) - expect(ruby).to be_a Gitlab::Template::MergeRequestTemplate + expect(ruby).to be_a described_class expect(ruby.name).to eq('bug') end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 7f21288cf88..bf1dfe7f412 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::UsageData do let!(:board) { create(:board, project: project) } describe '#data' do - subject { Gitlab::UsageData.data } + subject { described_class.data } it "gathers usage data" do expect(subject.keys).to match_array(%i( @@ -58,7 +58,7 @@ describe Gitlab::UsageData do end describe '#license_usage_data' do - subject { Gitlab::UsageData.license_usage_data } + subject { described_class.license_usage_data } it "gathers license data" do expect(subject[:uuid]).to eq(current_application_settings.uuid) diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index b703e9808a8..beb1791a429 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -181,10 +181,23 @@ describe Gitlab::Workhorse, lib: true do let(:user) { create(:user) } let(:repo_path) { repository.path_to_repo } let(:action) { 'info_refs' } + let(:params) do + { GL_ID: "user-#{user.id}", GL_REPOSITORY: "project-#{project.id}", RepoPath: repo_path } + end + + subject { described_class.git_http_ok(repository, false, user, action) } + + it { expect(subject).to include(params) } - subject { described_class.git_http_ok(repository, user, action) } + context 'when is_wiki' do + let(:params) do + { GL_ID: "user-#{user.id}", GL_REPOSITORY: "wiki-#{project.id}", RepoPath: repo_path } + end + + subject { described_class.git_http_ok(repository, true, user, action) } - it { expect(subject).to include({ GL_ID: "user-#{user.id}", RepoPath: repo_path }) } + it { expect(subject).to include(params) } + end context 'when Gitaly is enabled' do let(:gitaly_params) do diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index e6f0a3b5920..1e6260270fe 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -36,11 +36,11 @@ describe Notify do end context 'for issues' do - let(:issue) { create(:issue, author: current_user, assignee: assignee, project: project) } - let(:issue_with_description) { create(:issue, author: current_user, assignee: assignee, project: project, description: 'My awesome description') } + let(:issue) { create(:issue, author: current_user, assignees: [assignee], project: project) } + let(:issue_with_description) { create(:issue, author: current_user, assignees: [assignee], project: project, description: 'My awesome description') } describe 'that are new' do - subject { Notify.new_issue_email(issue.assignee_id, issue.id) } + subject { described_class.new_issue_email(issue.assignees.first.id, issue.id) } it_behaves_like 'an assignee email' it_behaves_like 'an email starting a new thread with reply-by-email enabled' do @@ -69,7 +69,7 @@ describe Notify do end describe 'that are new with a description' do - subject { Notify.new_issue_email(issue_with_description.assignee_id, issue_with_description.id) } + subject { described_class.new_issue_email(issue_with_description.assignees.first.id, issue_with_description.id) } it_behaves_like 'it should show Gmail Actions View Issue link' @@ -79,7 +79,7 @@ describe Notify do end describe 'that have been reassigned' do - subject { Notify.reassigned_issue_email(recipient.id, issue.id, previous_assignee.id, current_user.id) } + subject { described_class.reassigned_issue_email(recipient.id, issue.id, [previous_assignee.id], current_user.id) } it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -105,7 +105,7 @@ describe Notify do end describe 'that have been relabeled' do - subject { Notify.relabeled_issue_email(recipient.id, issue.id, %w[foo bar baz], current_user.id) } + subject { described_class.relabeled_issue_email(recipient.id, issue.id, %w[foo bar baz], current_user.id) } it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -132,7 +132,7 @@ describe Notify do describe 'status changed' do let(:status) { 'closed' } - subject { Notify.issue_status_changed_email(recipient.id, issue.id, status, current_user.id) } + subject { described_class.issue_status_changed_email(recipient.id, issue.id, status, current_user.id) } it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do let(:model) { issue } @@ -158,7 +158,7 @@ describe Notify do describe 'moved to another project' do let(:new_issue) { create(:issue) } - subject { Notify.issue_moved_email(recipient, issue, new_issue, current_user) } + subject { described_class.issue_moved_email(recipient, issue, new_issue, current_user) } it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do let(:model) { issue } @@ -190,7 +190,7 @@ describe Notify do let(:merge_request_with_description) { create(:merge_request, author: current_user, assignee: assignee, source_project: project, target_project: project, description: 'My awesome description') } describe 'that are new' do - subject { Notify.new_merge_request_email(merge_request.assignee_id, merge_request.id) } + subject { described_class.new_merge_request_email(merge_request.assignee_id, merge_request.id) } it_behaves_like 'an assignee email' it_behaves_like 'an email starting a new thread with reply-by-email enabled' do @@ -221,7 +221,7 @@ describe Notify do end describe 'that are new with a description' do - subject { Notify.new_merge_request_email(merge_request_with_description.assignee_id, merge_request_with_description.id) } + subject { described_class.new_merge_request_email(merge_request_with_description.assignee_id, merge_request_with_description.id) } it_behaves_like 'it should show Gmail Actions View Merge request link' it_behaves_like "an unsubscribeable thread" @@ -232,7 +232,7 @@ describe Notify do end describe 'that are reassigned' do - subject { Notify.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id) } + subject { described_class.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id) } it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -258,7 +258,7 @@ describe Notify do end describe 'that have been relabeled' do - subject { Notify.relabeled_merge_request_email(recipient.id, merge_request.id, %w[foo bar baz], current_user.id) } + subject { described_class.relabeled_merge_request_email(recipient.id, merge_request.id, %w[foo bar baz], current_user.id) } it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -283,7 +283,7 @@ describe Notify do describe 'status changed' do let(:status) { 'reopened' } - subject { Notify.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) } + subject { described_class.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) } it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do let(:model) { merge_request } @@ -308,7 +308,7 @@ describe Notify do end describe 'that are merged' do - subject { Notify.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) } + subject { described_class.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) } it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -337,7 +337,7 @@ describe Notify do describe 'project was moved' do let(:project) { create(:empty_project) } let(:user) { create(:user) } - subject { Notify.project_was_moved_email(project.id, user.id, "gitlab/gitlab") } + subject { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -363,7 +363,7 @@ describe Notify do project.request_access(user) project.requesters.find_by(user_id: user.id) end - subject { Notify.member_access_requested_email('project', project_member.id) } + subject { described_class.member_access_requested_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -390,7 +390,7 @@ describe Notify do project.request_access(user) project.requesters.find_by(user_id: user.id) end - subject { Notify.member_access_requested_email('project', project_member.id) } + subject { described_class.member_access_requested_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -416,7 +416,7 @@ describe Notify do project.request_access(user) project.requesters.find_by(user_id: user.id) end - subject { Notify.member_access_denied_email('project', project.id, user.id) } + subject { described_class.member_access_denied_email('project', project.id, user.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -434,7 +434,7 @@ describe Notify do let(:project) { create(:empty_project, :public, :access_requestable, namespace: owner.namespace) } let(:user) { create(:user) } let(:project_member) { create(:project_member, project: project, user: user) } - subject { Notify.member_access_granted_email('project', project_member.id) } + subject { described_class.member_access_granted_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -465,7 +465,7 @@ describe Notify do let(:master) { create(:user).tap { |u| project.team << [u, :master] } } let(:project_member) { invite_to_project(project, inviter: master) } - subject { Notify.member_invited_email('project', project_member.id, project_member.invite_token) } + subject { described_class.member_invited_email('project', project_member.id, project_member.invite_token) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -490,7 +490,7 @@ describe Notify do invitee end - subject { Notify.member_invite_accepted_email('project', project_member.id) } + subject { described_class.member_invite_accepted_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -514,7 +514,7 @@ describe Notify do invitee end - subject { Notify.member_invite_declined_email('project', project.id, project_member.invite_email, master.id) } + subject { described_class.member_invite_declined_email('project', project.id, project_member.invite_email, master.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -574,7 +574,7 @@ describe Notify do before(:each) { allow(note).to receive(:noteable).and_return(commit) } - subject { Notify.note_commit_email(recipient.id, note.id) } + subject { described_class.note_commit_email(recipient.id, note.id) } it_behaves_like 'a note email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -596,7 +596,7 @@ describe Notify do let(:note_on_merge_request_path) { namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: "note_#{note.id}") } before(:each) { allow(note).to receive(:noteable).and_return(merge_request) } - subject { Notify.note_merge_request_email(recipient.id, note.id) } + subject { described_class.note_merge_request_email(recipient.id, note.id) } it_behaves_like 'a note email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -618,7 +618,7 @@ describe Notify do let(:note_on_issue_path) { namespace_project_issue_path(project.namespace, project, issue, anchor: "note_#{note.id}") } before(:each) { allow(note).to receive(:noteable).and_return(issue) } - subject { Notify.note_issue_email(recipient.id, note.id) } + subject { described_class.note_issue_email(recipient.id, note.id) } it_behaves_like 'a note email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -680,7 +680,7 @@ describe Notify do before(:each) { allow(note).to receive(:noteable).and_return(commit) } - subject { Notify.note_commit_email(recipient.id, note.id) } + subject { described_class.note_commit_email(recipient.id, note.id) } it_behaves_like 'a discussion note email', :discussion_note_on_commit it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -704,7 +704,7 @@ describe Notify do let(:note_on_merge_request_path) { namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: "note_#{note.id}") } before(:each) { allow(note).to receive(:noteable).and_return(merge_request) } - subject { Notify.note_merge_request_email(recipient.id, note.id) } + subject { described_class.note_merge_request_email(recipient.id, note.id) } it_behaves_like 'a discussion note email', :discussion_note_on_merge_request it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -728,7 +728,7 @@ describe Notify do let(:note_on_issue_path) { namespace_project_issue_path(project.namespace, project, issue, anchor: "note_#{note.id}") } before(:each) { allow(note).to receive(:noteable).and_return(issue) } - subject { Notify.note_issue_email(recipient.id, note.id) } + subject { described_class.note_issue_email(recipient.id, note.id) } it_behaves_like 'a discussion note email', :discussion_note_on_issue it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -798,7 +798,7 @@ describe Notify do let(:commit) { project.commit } let(:note) { create(:diff_note_on_commit) } - subject { Notify.note_commit_email(recipient.id, note.id) } + subject { described_class.note_commit_email(recipient.id, note.id) } it_behaves_like 'an email for a note on a diff discussion', :diff_note_on_commit it_behaves_like 'it should show Gmail Actions View Commit link' @@ -809,7 +809,7 @@ describe Notify do let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } let(:note) { create(:diff_note_on_merge_request) } - subject { Notify.note_merge_request_email(recipient.id, note.id) } + subject { described_class.note_merge_request_email(recipient.id, note.id) } it_behaves_like 'an email for a note on a diff discussion', :diff_note_on_merge_request it_behaves_like 'it should show Gmail Actions View Merge request link' @@ -826,7 +826,7 @@ describe Notify do group.request_access(user) group.requesters.find_by(user_id: user.id) end - subject { Notify.member_access_requested_email('group', group_member.id) } + subject { described_class.member_access_requested_email('group', group_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -847,7 +847,7 @@ describe Notify do group.request_access(user) group.requesters.find_by(user_id: user.id) end - subject { Notify.member_access_denied_email('group', group.id, user.id) } + subject { described_class.member_access_denied_email('group', group.id, user.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -865,7 +865,7 @@ describe Notify do let(:user) { create(:user) } let(:group_member) { create(:group_member, group: group, user: user) } - subject { Notify.member_access_granted_email('group', group_member.id) } + subject { described_class.member_access_granted_email('group', group_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -896,7 +896,7 @@ describe Notify do let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } let(:group_member) { invite_to_group(group, inviter: owner) } - subject { Notify.member_invited_email('group', group_member.id, group_member.invite_token) } + subject { described_class.member_invited_email('group', group_member.id, group_member.invite_token) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -921,7 +921,7 @@ describe Notify do invitee end - subject { Notify.member_invite_accepted_email('group', group_member.id) } + subject { described_class.member_invite_accepted_email('group', group_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -945,7 +945,7 @@ describe Notify do invitee end - subject { Notify.member_invite_declined_email('group', group.id, group_member.invite_email, owner.id) } + subject { described_class.member_invite_declined_email('group', group.id, group_member.invite_email, owner.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -994,7 +994,7 @@ describe Notify do let(:user) { create(:user) } let(:tree_path) { namespace_project_tree_path(project.namespace, project, "empty-branch") } - subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/empty-branch', action: :create) } + subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/empty-branch', action: :create) } it_behaves_like 'it should not have Gmail Actions links' it_behaves_like 'a user cannot unsubscribe through footer link' @@ -1020,7 +1020,7 @@ describe Notify do let(:user) { create(:user) } let(:tree_path) { namespace_project_tree_path(project.namespace, project, "v1.0") } - subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :create) } + subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :create) } it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" @@ -1045,7 +1045,7 @@ describe Notify do let(:example_site_path) { root_path } let(:user) { create(:user) } - subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :delete) } + subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :delete) } it_behaves_like 'it should not have Gmail Actions links' it_behaves_like 'a user cannot unsubscribe through footer link' @@ -1067,7 +1067,7 @@ describe Notify do let(:example_site_path) { root_path } let(:user) { create(:user) } - subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :delete) } + subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :delete) } it_behaves_like 'it should not have Gmail Actions links' it_behaves_like 'a user cannot unsubscribe through footer link' @@ -1096,7 +1096,7 @@ describe Notify do let(:send_from_committer_email) { false } let(:diff_refs) { Gitlab::Diff::DiffRefs.new(base_sha: project.merge_base_commit(sample_image_commit.id, sample_commit.id).id, head_sha: sample_commit.id) } - subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, reverse_compare: false, diff_refs: diff_refs, send_from_committer_email: send_from_committer_email) } + subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, reverse_compare: false, diff_refs: diff_refs, send_from_committer_email: send_from_committer_email) } it_behaves_like 'it should not have Gmail Actions links' it_behaves_like 'a user cannot unsubscribe through footer link' @@ -1189,7 +1189,7 @@ describe Notify do let(:diff_path) { namespace_project_commit_path(project.namespace, project, commits.first) } let(:diff_refs) { Gitlab::Diff::DiffRefs.new(base_sha: project.merge_base_commit(sample_image_commit.id, sample_commit.id).id, head_sha: sample_commit.id) } - subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, diff_refs: diff_refs) } + subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, diff_refs: diff_refs) } it_behaves_like 'it should show Gmail Actions View Commit link' it_behaves_like 'a user cannot unsubscribe through footer link' @@ -1215,7 +1215,7 @@ describe Notify do describe 'HTML emails setting' do let(:project) { create(:empty_project) } let(:user) { create(:user) } - let(:multipart_mail) { Notify.project_was_moved_email(project.id, user.id, "gitlab/gitlab") } + let(:multipart_mail) { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") } context 'when disabled' do it 'only sends the text template' do diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 7e8a1c8add7..f84c6b48173 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -35,8 +35,68 @@ describe Blob do end end + describe '#external_storage_error?' do + context 'if the blob is stored in LFS' do + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } + + context 'when the project has LFS enabled' do + it 'returns false' do + expect(blob.external_storage_error?).to be_falsey + end + end + + context 'when the project does not have LFS enabled' do + before do + project.lfs_enabled = false + end + + it 'returns true' do + expect(blob.external_storage_error?).to be_truthy + end + end + end + + context 'if the blob is not stored in LFS' do + let(:blob) { fake_blob(path: 'file.md') } + + it 'returns false' do + expect(blob.external_storage_error?).to be_falsey + end + end + end + + describe '#stored_externally?' do + context 'if the blob is stored in LFS' do + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } + + context 'when the project has LFS enabled' do + it 'returns true' do + expect(blob.stored_externally?).to be_truthy + end + end + + context 'when the project does not have LFS enabled' do + before do + project.lfs_enabled = false + end + + it 'returns false' do + expect(blob.stored_externally?).to be_falsey + end + end + end + + context 'if the blob is not stored in LFS' do + let(:blob) { fake_blob(path: 'file.md') } + + it 'returns false' do + expect(blob.stored_externally?).to be_falsey + end + end + end + describe '#raw_binary?' do - context 'if the blob is a valid LFS pointer' do + context 'if the blob is stored externally' do context 'if the extension has a rich viewer' do context 'if the viewer is binary' do it 'returns true' do @@ -56,15 +116,63 @@ describe Blob do end context "if the extension doesn't have a rich viewer" do - it 'returns true' do - blob = fake_blob(path: 'file.exe', lfs: true) + context 'if the extension has a text mime type' do + context 'if the extension is for a programming language' do + it 'returns false' do + blob = fake_blob(path: 'file.txt', lfs: true) - expect(blob.raw_binary?).to be_truthy + expect(blob.raw_binary?).to be_falsey + end + end + + context 'if the extension is not for a programming language' do + it 'returns false' do + blob = fake_blob(path: 'file.ics', lfs: true) + + expect(blob.raw_binary?).to be_falsey + end + end + end + + context 'if the extension has a binary mime type' do + context 'if the extension is for a programming language' do + it 'returns false' do + blob = fake_blob(path: 'file.rb', lfs: true) + + expect(blob.raw_binary?).to be_falsey + end + end + + context 'if the extension is not for a programming language' do + it 'returns true' do + blob = fake_blob(path: 'file.exe', lfs: true) + + expect(blob.raw_binary?).to be_truthy + end + end + end + + context 'if the extension has an unknown mime type' do + context 'if the extension is for a programming language' do + it 'returns false' do + blob = fake_blob(path: 'file.ini', lfs: true) + + expect(blob.raw_binary?).to be_falsey + end + end + + context 'if the extension is not for a programming language' do + it 'returns true' do + blob = fake_blob(path: 'file.wtf', lfs: true) + + expect(blob.raw_binary?).to be_truthy + end + end end end end - context 'if the blob is not an LFS pointer' do + context 'if the blob is not stored externally' do context 'if the blob is binary' do it 'returns true' do blob = fake_blob(path: 'file.pdf', binary: true) @@ -94,7 +202,7 @@ describe Blob do describe '#simple_viewer' do context 'when the blob is empty' do it 'returns an empty viewer' do - blob = fake_blob(data: '') + blob = fake_blob(data: '', size: 0) expect(blob.simple_viewer).to be_a(BlobViewer::Empty) end @@ -118,7 +226,7 @@ describe Blob do end describe '#rich_viewer' do - context 'when the blob is an invalid LFS pointer' do + context 'when the blob has an external storage error' do before do project.lfs_enabled = false end @@ -138,7 +246,7 @@ describe Blob do end end - context 'when the blob is a valid LFS pointer' do + context 'when the blob is stored externally' do it 'returns a matching viewer' do blob = fake_blob(path: 'file.pdf', lfs: true) diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb index a3e598de56d..740ad9d275e 100644 --- a/spec/models/blob_viewer/base_spec.rb +++ b/spec/models/blob_viewer/base_spec.rb @@ -139,7 +139,7 @@ describe BlobViewer::Base, model: true do end end - context 'when the viewer is server side but the blob is stored in LFS' do + context 'when the viewer is server side but the blob is stored externally' do let(:project) { build(:empty_project, lfs_enabled: true) } let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } @@ -148,8 +148,8 @@ describe BlobViewer::Base, model: true do allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) end - it 'return :server_side_but_stored_in_lfs' do - expect(viewer.render_error).to eq(:server_side_but_stored_in_lfs) + it 'return :server_side_but_stored_externally' do + expect(viewer.render_error).to eq(:server_side_but_stored_externally) end end end diff --git a/spec/models/ci/artifact_blob_spec.rb b/spec/models/ci/artifact_blob_spec.rb new file mode 100644 index 00000000000..968593d7e9b --- /dev/null +++ b/spec/models/ci/artifact_blob_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Ci::ArtifactBlob, models: true do + let(:build) { create(:ci_build, :artifacts) } + let(:entry) { build.artifacts_metadata_entry('other_artifacts_0.1.2/another-subdirectory/banana_sample.gif') } + + subject { described_class.new(entry) } + + describe '#id' do + it 'returns a hash of the path' do + expect(subject.id).to eq(Digest::SHA1.hexdigest(entry.path)) + end + end + + describe '#name' do + it 'returns the entry name' do + expect(subject.name).to eq(entry.name) + end + end + + describe '#path' do + it 'returns the entry path' do + expect(subject.path).to eq(entry.path) + end + end + + describe '#size' do + it 'returns the entry size' do + expect(subject.size).to eq(entry.metadata[:size]) + end + end + + describe '#mode' do + it 'returns the entry mode' do + expect(subject.mode).to eq(entry.metadata[:mode]) + end + end + + describe '#external_storage' do + it 'returns :build_artifact' do + expect(subject.external_storage).to eq(:build_artifact) + end + end +end diff --git a/spec/models/ci/group_spec.rb b/spec/models/ci/group_spec.rb new file mode 100644 index 00000000000..62e15093089 --- /dev/null +++ b/spec/models/ci/group_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Ci::Group, models: true do + subject do + described_class.new('test', name: 'rspec', jobs: jobs) + end + + let!(:jobs) { build_list(:ci_build, 1, :success) } + + it { is_expected.to include_module(StaticModel) } + + it { is_expected.to respond_to(:stage) } + it { is_expected.to respond_to(:name) } + it { is_expected.to respond_to(:jobs) } + it { is_expected.to respond_to(:status) } + + describe '#size' do + it 'returns the number of statuses in the group' do + expect(subject.size).to eq(1) + end + end + + describe '#detailed_status' do + context 'when there is only one item in the group' do + it 'calls the status from the object itself' do + expect(jobs.first).to receive(:detailed_status) + + expect(subject.detailed_status(double(:user))) + end + end + + context 'when there are more than one commit status in the group' do + let(:jobs) do + [create(:ci_build, :failed), + create(:ci_build, :success)] + end + + it 'fabricates a new detailed status object' do + expect(subject.detailed_status(double(:user))) + .to be_a(Gitlab::Ci::Status::Failed) + end + end + end +end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index c38faf32f7d..372b662fab2 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -28,6 +28,35 @@ describe Ci::Stage, models: true do end end + describe '#groups' do + before do + create_job(:ci_build, name: 'rspec 0 2') + create_job(:ci_build, name: 'rspec 0 1') + create_job(:ci_build, name: 'spinach 0 1') + create_job(:commit_status, name: 'aaaaa') + end + + it 'returns an array of three groups' do + expect(stage.groups).to be_a Array + expect(stage.groups).to all(be_a Ci::Group) + expect(stage.groups.size).to eq 3 + end + + it 'returns groups with correctly ordered statuses' do + expect(stage.groups.first.jobs.map(&:name)) + .to eq ['aaaaa'] + expect(stage.groups.second.jobs.map(&:name)) + .to eq ['rspec 0 1', 'rspec 0 2'] + expect(stage.groups.third.jobs.map(&:name)) + .to eq ['spinach 0 1'] + end + + it 'returns groups with correct names' do + expect(stage.groups.map(&:name)) + .to eq %w[aaaaa rspec spinach] + end + end + describe '#statuses_count' do before do create_job(:ci_build) @@ -223,7 +252,7 @@ describe Ci::Stage, models: true do end end - def create_job(type, status: 'success', stage: stage_name) - create(type, pipeline: pipeline, stage: stage, status: status) + def create_job(type, status: 'success', stage: stage_name, **opts) + create(type, pipeline: pipeline, stage: stage, status: status, **opts) end end diff --git a/spec/models/ci/trigger_schedule_spec.rb b/spec/models/ci/trigger_schedule_spec.rb index 75d21541cee..92447564d7c 100644 --- a/spec/models/ci/trigger_schedule_spec.rb +++ b/spec/models/ci/trigger_schedule_spec.rb @@ -73,4 +73,36 @@ describe Ci::TriggerSchedule, models: true do end end end + + describe '#real_next_run' do + subject do + Ci::TriggerSchedule.last.real_next_run(worker_cron: worker_cron, + worker_time_zone: worker_time_zone) + end + + context 'when GitLab time_zone is UTC' do + before do + allow(Time).to receive(:zone) + .and_return(ActiveSupport::TimeZone[worker_time_zone]) + end + + let(:worker_time_zone) { 'UTC' } + + context 'when cron_timezone is Eastern Time (US & Canada)' do + before do + create(:ci_trigger_schedule, :nightly, + cron_timezone: 'Eastern Time (US & Canada)') + end + + let(:worker_cron) { '0 1 2 3 *' } + + it 'returns the next time worker executes' do + expect(subject.min).to eq(0) + expect(subject.hour).to eq(1) + expect(subject.day).to eq(2) + expect(subject.month).to eq(3) + end + end + end + end end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index ce31c8ed94c..08b2169fea7 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -212,7 +212,7 @@ eos end end - describe '#latest_pipeline' do + describe '#last_pipeline' do let!(:first_pipeline) do create(:ci_empty_pipeline, project: project, @@ -226,8 +226,8 @@ eos status: 'success') end - it 'returns latest pipeline' do - expect(commit.latest_pipeline).to eq second_pipeline + it 'returns last pipeline' do + expect(commit.last_pipeline).to eq second_pipeline end end diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 4edafbc4e32..40bbb10eaac 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -170,6 +170,12 @@ describe CacheMarkdownField do is_expected.to be_truthy end + + it 'returns false if the markdown field is set but the html is not' do + thing.foo_html = nil + + is_expected.to be_falsy + end end describe '#refresh_markdown_cache!' do diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 3ecba2e9687..27890e33b49 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -10,7 +10,6 @@ describe Issuable do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:author) } - it { is_expected.to belong_to(:assignee) } it { is_expected.to have_many(:notes).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } @@ -66,60 +65,6 @@ describe Issuable do end end - describe 'assignee_name' do - it 'is delegated to assignee' do - issue.update!(assignee: create(:user)) - - expect(issue.assignee_name).to eq issue.assignee.name - end - - it 'returns nil when assignee is nil' do - issue.assignee_id = nil - issue.save(validate: false) - - expect(issue.assignee_name).to eq nil - end - end - - describe "before_save" do - describe "#update_cache_counts" do - context "when previous assignee exists" do - before do - assignee = create(:user) - issue.project.team << [assignee, :developer] - issue.update(assignee: assignee) - end - - it "updates cache counts for new assignee" do - user = create(:user) - - expect(user).to receive(:update_cache_counts) - - issue.update(assignee: user) - end - - it "updates cache counts for previous assignee" do - old_assignee = issue.assignee - allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) - - expect(old_assignee).to receive(:update_cache_counts) - - issue.update(assignee: nil) - end - end - - context "when previous assignee does not exist" do - before{ issue.update(assignee: nil) } - - it "updates cache count for the new assignee" do - expect_any_instance_of(User).to receive(:update_cache_counts) - - issue.update(assignee: user) - end - end - end - end - describe ".search" do let!(:searchable_issue) { create(:issue, title: "Searchable issue") } @@ -307,7 +252,20 @@ describe Issuable do end context "issue is assigned" do - before { issue.update_attribute(:assignee, user) } + before { issue.assignees << user } + + it "returns correct hook data" do + expect(data[:assignees].first).to eq(user.hook_attrs) + end + end + + context "merge_request is assigned" do + let(:merge_request) { create(:merge_request) } + let(:data) { merge_request.to_hook_data(user) } + + before do + merge_request.update_attribute(:assignee, user) + end it "returns correct hook data" do expect(data[:object_attributes]['assignee_id']).to eq(user.id) @@ -329,24 +287,6 @@ describe Issuable do include_examples 'deprecated repository hook data' end - describe '#card_attributes' do - it 'includes the author name' do - allow(issue).to receive(:author).and_return(double(name: 'Robert')) - allow(issue).to receive(:assignee).and_return(nil) - - expect(issue.card_attributes). - to eq({ 'Author' => 'Robert', 'Assignee' => nil }) - end - - it 'includes the assignee name' do - allow(issue).to receive(:author).and_return(double(name: 'Robert')) - allow(issue).to receive(:assignee).and_return(double(name: 'Douwe')) - - expect(issue.card_attributes). - to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) - end - end - describe '#labels_array' do let(:project) { create(:empty_project) } let(:bug) { create(:label, project: project, title: 'bug') } @@ -475,27 +415,6 @@ describe Issuable do end end - describe '#assignee_or_author?' do - let(:user) { build(:user, id: 1) } - let(:issue) { build(:issue) } - - it 'returns true for a user that is assigned to an issue' do - issue.assignee = user - - expect(issue.assignee_or_author?(user)).to eq(true) - end - - it 'returns true for a user that is the author of an issue' do - issue.author = user - - expect(issue.assignee_or_author?(user)).to eq(true) - end - - it 'returns false for a user that is not the assignee or author' do - expect(issue.assignee_or_author?(user)).to eq(false) - end - end - describe '#spend_time' do let(:user) { create(:user) } let(:issue) { create(:issue) } diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index 68e4c0a522b..675b730c557 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -11,13 +11,13 @@ describe Milestone, 'Milestoneish' do let(:milestone) { create(:milestone, project: project) } let!(:issue) { create(:issue, project: project, milestone: milestone) } let!(:security_issue_1) { create(:issue, :confidential, project: project, author: author, milestone: milestone) } - let!(:security_issue_2) { create(:issue, :confidential, project: project, assignee: assignee, milestone: milestone) } + let!(:security_issue_2) { create(:issue, :confidential, project: project, assignees: [assignee], milestone: milestone) } let!(:closed_issue_1) { create(:issue, :closed, project: project, milestone: milestone) } let!(:closed_issue_2) { create(:issue, :closed, project: project, milestone: milestone) } let!(:closed_security_issue_1) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } - let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) } let!(:closed_security_issue_3) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } - let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) } let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) } before do diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 221647d7a48..49a4132f763 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -9,6 +9,7 @@ describe Group, 'Routable' do describe 'Associations' do it { is_expected.to have_one(:route).dependent(:destroy) } + it { is_expected.to have_many(:redirect_routes).dependent(:destroy) } end describe 'Callbacks' do @@ -35,10 +36,53 @@ describe Group, 'Routable' do describe '.find_by_full_path' do let!(:nested_group) { create(:group, parent: group) } - it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) } - it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) } - it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) } - it { expect(described_class.find_by_full_path('unknown')).to eq(nil) } + context 'without any redirect routes' do + it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) } + it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) } + it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) } + it { expect(described_class.find_by_full_path('unknown')).to eq(nil) } + end + + context 'with redirect routes' do + let!(:group_redirect_route) { group.redirect_routes.create!(path: 'bar') } + let!(:nested_group_redirect_route) { nested_group.redirect_routes.create!(path: nested_group.path.sub('foo', 'bar')) } + + context 'without follow_redirects option' do + context 'with the given path not matching any route' do + it { expect(described_class.find_by_full_path('unknown')).to eq(nil) } + end + + context 'with the given path matching the canonical route' do + it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) } + it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) } + it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) } + end + + context 'with the given path matching a redirect route' do + it { expect(described_class.find_by_full_path(group_redirect_route.path)).to eq(nil) } + it { expect(described_class.find_by_full_path(group_redirect_route.path.upcase)).to eq(nil) } + it { expect(described_class.find_by_full_path(nested_group_redirect_route.path)).to eq(nil) } + end + end + + context 'with follow_redirects option set to true' do + context 'with the given path not matching any route' do + it { expect(described_class.find_by_full_path('unknown', follow_redirects: true)).to eq(nil) } + end + + context 'with the given path matching the canonical route' do + it { expect(described_class.find_by_full_path(group.to_param, follow_redirects: true)).to eq(group) } + it { expect(described_class.find_by_full_path(group.to_param.upcase, follow_redirects: true)).to eq(group) } + it { expect(described_class.find_by_full_path(nested_group.to_param, follow_redirects: true)).to eq(nested_group) } + end + + context 'with the given path matching a redirect route' do + it { expect(described_class.find_by_full_path(group_redirect_route.path, follow_redirects: true)).to eq(group) } + it { expect(described_class.find_by_full_path(group_redirect_route.path.upcase, follow_redirects: true)).to eq(group) } + it { expect(described_class.find_by_full_path(nested_group_redirect_route.path, follow_redirects: true)).to eq(nested_group) } + end + end + end end describe '.where_full_path_in' do diff --git a/spec/models/diff_discussion_spec.rb b/spec/models/diff_discussion_spec.rb index 48e7c0a822c..81f338745b1 100644 --- a/spec/models/diff_discussion_spec.rb +++ b/spec/models/diff_discussion_spec.rb @@ -1,19 +1,86 @@ require 'spec_helper' describe DiffDiscussion, model: true do - subject { described_class.new([first_note, second_note, third_note]) } + include RepoHelpers - let(:first_note) { create(:diff_note_on_merge_request) } - let(:merge_request) { first_note.noteable } - let(:project) { first_note.project } - let(:second_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) } - let(:third_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) } + subject { described_class.new([diff_note]) } + + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } describe '#reply_attributes' do it 'includes position and original_position' do attributes = subject.reply_attributes - expect(attributes[:position]).to eq(first_note.position.to_json) - expect(attributes[:original_position]).to eq(first_note.original_position.to_json) + expect(attributes[:position]).to eq(diff_note.position.to_json) + expect(attributes[:original_position]).to eq(diff_note.original_position.to_json) + end + end + + describe '#merge_request_version_params' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project, importing: true) } + let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) } + let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + + context 'when the discussion is active' do + it 'returns an empty hash, which will end up showing the latest version' do + expect(subject.merge_request_version_params).to eq({}) + end + end + + context 'when the discussion is on an older merge request version' do + let(:position) do + Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: nil, + new_line: 4, + diff_refs: merge_request_diff1.diff_refs + ) + end + + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) } + + before do + diff_note.position = diff_note.original_position + diff_note.save! + end + + it 'returns the diff ID for the version to show' do + expect(diff_id: merge_request_diff1.id) + end + end + + context 'when the discussion is on a comparison between merge request versions' do + let(:position) do + Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: 4, + new_line: 4, + diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs + ) + end + + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) } + + it 'returns the diff ID and start sha of the versions to compare' do + expect(subject.merge_request_version_params).to eq(diff_id: merge_request_diff3.id, start_sha: merge_request_diff1.head_commit_sha) + end + end + + context 'when the discussion does not have a merge request version' do + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, diff_refs: project.commit(sample_commit.id).diff_refs) } + + before do + diff_note.position = diff_note.original_position + diff_note.save! + end + + it 'returns nil' do + expect(subject.merge_request_version_params).to be_nil + end end end end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index f32b6b99b3d..ab4c51a87b0 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -155,23 +155,6 @@ describe DiffNote, models: true do end end - describe '#latest_merge_request_diff' do - context 'when active' do - it 'returns the current merge request diff' do - expect(subject.latest_merge_request_diff).to eq(merge_request.merge_request_diff) - end - end - - context 'when outdated' do - let!(:old_merge_request_diff) { merge_request.merge_request_diff } - let!(:new_merge_request_diff) { merge_request.merge_request_diffs.create(diff_refs: commit.diff_refs) } - - it 'returns the latest merge request diff that this diff note applied to' do - expect(subject.latest_merge_request_diff).to eq(old_merge_request_diff) - end - end - end - describe "creation" do describe "updating of position" do context "when noteable is a commit" do @@ -256,4 +239,39 @@ describe DiffNote, models: true do end end end + + describe '#created_at_diff?' do + let(:diff_refs) { project.commit(sample_commit.id).diff_refs } + let(:position) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: diff_refs + ) + end + + context "when noteable is a commit" do + subject { build(:diff_note_on_commit, project: project, position: position) } + + it "returns true" do + expect(subject.created_at_diff?(diff_refs)).to be true + end + end + + context "when noteable is a merge request" do + context "when the diff refs match the original one of the diff note" do + it "returns true" do + expect(subject.created_at_diff?(diff_refs)).to be true + end + end + + context "when the diff refs don't match the original one of the diff note" do + it "returns false" do + expect(subject.created_at_diff?(merge_request.diff_refs)).to be false + end + end + end + end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 8c90a538f57..b8cb967c4cc 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -15,13 +15,39 @@ describe Event, models: true do end describe 'Callbacks' do - describe 'after_create :reset_project_activity' do - let(:project) { create(:empty_project) } + let(:project) { create(:empty_project) } + describe 'after_create :reset_project_activity' do it 'calls the reset_project_activity method' do expect_any_instance_of(described_class).to receive(:reset_project_activity) - create_event(project, project.owner) + create_push_event(project, project.owner) + end + end + + describe 'after_create :set_last_repository_updated_at' do + context 'with a push event' do + it 'updates the project last_repository_updated_at' do + project.update(last_repository_updated_at: 1.year.ago) + + create_push_event(project, project.owner) + + project.reload + + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) + end + end + + context 'without a push event' do + it 'does not update the project last_repository_updated_at' do + project.update(last_repository_updated_at: 1.year.ago) + + create(:closed_issue_event, project: project, author: project.owner) + + project.reload + + expect(project.last_repository_updated_at).to be_within(1.minute).of(1.year.ago) + end end end end @@ -29,7 +55,7 @@ describe Event, models: true do describe "Push event" do let(:project) { create(:empty_project, :private) } let(:user) { project.owner } - let(:event) { create_event(project, user) } + let(:event) { create_push_event(project, user) } it do expect(event.push?).to be_truthy @@ -92,8 +118,8 @@ describe Event, models: true do let(:author) { create(:author) } let(:assignee) { create(:user) } let(:admin) { create(:admin) } - let(:issue) { create(:issue, project: project, author: author, assignee: assignee) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:issue) { create(:issue, project: project, author: author, assignees: [assignee]) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note_on_commit) { create(:note_on_commit, project: project) } let(:note_on_issue) { create(:note_on_issue, noteable: issue, project: project) } let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project) } @@ -243,7 +269,7 @@ describe Event, models: true do expect(project).not_to receive(:update_column). with(:last_activity_at, a_kind_of(Time)) - create_event(project, project.owner) + create_push_event(project, project.owner) end end @@ -251,11 +277,11 @@ describe Event, models: true do it 'updates the project' do project.update(last_activity_at: 1.year.ago) - create_event(project, project.owner) + create_push_event(project, project.owner) project.reload - project.last_activity_at <= 1.minute.ago + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) end end end @@ -278,7 +304,7 @@ describe Event, models: true do end end - def create_event(project, user, attrs = {}) + def create_push_event(project, user, attrs = {}) data = { before: Gitlab::Git::BLANK_SHA, after: "0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e", diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 8ffde6f7fbb..3d60e52f23f 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -57,6 +57,32 @@ describe Group, models: true do it { is_expected.not_to validate_presence_of :owner } it { is_expected.to validate_presence_of :two_factor_grace_period } it { is_expected.to validate_numericality_of(:two_factor_grace_period).is_greater_than_or_equal_to(0) } + + describe 'path validation' do + it 'rejects paths reserved on the root namespace when the group has no parent' do + group = build(:group, path: 'api') + + expect(group).not_to be_valid + end + + it 'allows root paths when the group has a parent' do + group = build(:group, path: 'api', parent: create(:group)) + + expect(group).to be_valid + end + + it 'rejects any wildcard paths when not a top level group' do + group = build(:group, path: 'tree', parent: create(:group)) + + expect(group).not_to be_valid + end + + it 'rejects reserved group paths' do + group = build(:group, path: 'activity', parent: create(:group)) + + expect(group).not_to be_valid + end + end end describe '.visible_to_user' do @@ -149,6 +175,22 @@ describe Group, models: true do end end + describe '#avatar_url' do + let!(:group) { create(:group, :access_requestable, :with_avatar) } + let(:user) { create(:user) } + subject { group.avatar_url } + + context 'when avatar file is uploaded' do + before do + group.add_master(user) + end + + let(:avatar_path) { "/uploads/group/avatar/#{group.id}/dk.png" } + + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + end + end + describe '.search' do it 'returns groups with a matching name' do expect(described_class.search(group.name)).to eq([group]) diff --git a/spec/models/issue_collection_spec.rb b/spec/models/issue_collection_spec.rb index d8aed25c041..93c2c538e10 100644 --- a/spec/models/issue_collection_spec.rb +++ b/spec/models/issue_collection_spec.rb @@ -28,7 +28,7 @@ describe IssueCollection do end it 'returns the issues the user is assigned to' do - issue1.assignee = user + issue1.assignees << user expect(collection.updatable_by_user(user)).to eq([issue1]) end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 11befd4edfe..725f5c2311f 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe Issue, models: true do describe "Associations" do it { is_expected.to belong_to(:milestone) } + it { is_expected.to have_many(:assignees) } end describe 'modules' do @@ -37,6 +38,64 @@ describe Issue, models: true do end end + describe "before_save" do + describe "#update_cache_counts when an issue is reassigned" do + let(:issue) { create(:issue) } + let(:assignee) { create(:user) } + + context "when previous assignee exists" do + before do + issue.project.team << [assignee, :developer] + issue.assignees << assignee + end + + it "updates cache counts for new assignee" do + user = create(:user) + + expect(user).to receive(:update_cache_counts) + + issue.assignees << user + end + + it "updates cache counts for previous assignee" do + issue.assignees.first + + expect_any_instance_of(User).to receive(:update_cache_counts) + + issue.assignees.destroy_all + end + end + + context "when previous assignee does not exist" do + it "updates cache count for the new assignee" do + issue.assignees = [] + + expect_any_instance_of(User).to receive(:update_cache_counts) + + issue.assignees << assignee + end + end + end + end + + describe '#card_attributes' do + it 'includes the author name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignees).and_return([]) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => '' }) + end + + it 'includes the assignee name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignees).and_return([double(name: 'Douwe')]) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) + end + end + describe '#closed_at' do after do Timecop.return @@ -124,13 +183,24 @@ describe Issue, models: true do end end - describe '#is_being_reassigned?' do - it 'returns true if the issue assignee has changed' do - subject.assignee = create(:user) - expect(subject.is_being_reassigned?).to be_truthy + describe '#assignee_or_author?' do + let(:user) { create(:user) } + let(:issue) { create(:issue) } + + it 'returns true for a user that is assigned to an issue' do + issue.assignees << user + + expect(issue.assignee_or_author?(user)).to be_truthy end - it 'returns false if the issue assignee has not changed' do - expect(subject.is_being_reassigned?).to be_falsey + + it 'returns true for a user that is the author of an issue' do + issue.update(author: user) + + expect(issue.assignee_or_author?(user)).to be_truthy + end + + it 'returns false for a user that is not the assignee or author' do + expect(issue.assignee_or_author?(user)).to be_falsey end end @@ -291,6 +361,27 @@ describe Issue, models: true do end end + describe '#has_related_branch?' do + let(:issue) { create(:issue, title: "Blue Bell Knoll") } + subject { issue.has_related_branch? } + + context 'branch found' do + before do + allow(issue.project.repository).to receive(:branch_names).and_return(["iceblink-luck", issue.to_branch_name]) + end + + it { is_expected.to eq true } + end + + context 'branch not found' do + before do + allow(issue.project.repository).to receive(:branch_names).and_return(["lazy-calm"]) + end + + it { is_expected.to eq false } + end + end + it_behaves_like 'an editable mentionable' do subject { create(:issue, project: create(:project, :repository)) } @@ -362,14 +453,14 @@ describe Issue, models: true do user1 = create(:user) user2 = create(:user) project = create(:empty_project) - issue = create(:issue, assignee: user1, project: project) + issue = create(:issue, assignees: [user1], project: project) project.add_developer(user1) project.add_developer(user2) expect(user1.assigned_open_issues_count).to eq(1) expect(user2.assigned_open_issues_count).to eq(0) - issue.assignee = user2 + issue.assignees = [user2] issue.save expect(user1.assigned_open_issues_count).to eq(0) @@ -655,6 +746,11 @@ describe Issue, models: true do expect(attrs_hash).to include(:human_total_time_spent) expect(attrs_hash).to include('time_estimate') end + + it 'includes assignee_ids and deprecated assignee_id' do + expect(attrs_hash).to include(:assignee_id) + expect(attrs_hash).to include(:assignee_ids) + end end describe '#check_for_spam' do diff --git a/spec/models/legacy_diff_discussion_spec.rb b/spec/models/legacy_diff_discussion_spec.rb index 153e757a0ef..6eb4a2aaf39 100644 --- a/spec/models/legacy_diff_discussion_spec.rb +++ b/spec/models/legacy_diff_discussion_spec.rb @@ -8,4 +8,26 @@ describe LegacyDiffDiscussion, models: true do expect(subject.reply_attributes[:line_code]).to eq(subject.line_code) end end + + describe '#merge_request_version_params' do + context 'when the discussion is active' do + before do + allow(subject).to receive(:active?).and_return(true) + end + + it 'returns an empty hash, which will end up showing the latest version' do + expect(subject.merge_request_version_params).to eq({}) + end + end + + context 'when the discussion is outdated' do + before do + allow(subject).to receive(:active?).and_return(false) + end + + it 'returns nil' do + expect(subject.merge_request_version_params).to be_nil + end + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index be08b96641a..6cf3dd30ead 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -9,6 +9,7 @@ describe MergeRequest, models: true do it { is_expected.to belong_to(:target_project).class_name('Project') } it { is_expected.to belong_to(:source_project).class_name('Project') } it { is_expected.to belong_to(:merge_user).class_name("User") } + it { is_expected.to belong_to(:assignee) } it { is_expected.to have_many(:merge_request_diffs).dependent(:destroy) } end @@ -86,6 +87,86 @@ describe MergeRequest, models: true do end end + describe "before_save" do + describe "#update_cache_counts when a merge request is reassigned" do + let(:project) { create :project } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:assignee) { create :user } + + context "when previous assignee exists" do + before do + project.team << [assignee, :developer] + merge_request.update(assignee: assignee) + end + + it "updates cache counts for new assignee" do + user = create(:user) + + expect(user).to receive(:update_cache_counts) + + merge_request.update(assignee: user) + end + + it "updates cache counts for previous assignee" do + old_assignee = merge_request.assignee + allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) + + expect(old_assignee).to receive(:update_cache_counts) + + merge_request.update(assignee: nil) + end + end + + context "when previous assignee does not exist" do + it "updates cache count for the new assignee" do + merge_request.update(assignee: nil) + + expect_any_instance_of(User).to receive(:update_cache_counts) + + merge_request.update(assignee: assignee) + end + end + end + end + + describe '#card_attributes' do + it 'includes the author name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignee).and_return(nil) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => nil }) + end + + it 'includes the assignee name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignee).and_return(double(name: 'Douwe')) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) + end + end + + describe '#assignee_or_author?' do + let(:user) { create(:user) } + + it 'returns true for a user that is assigned to a merge request' do + subject.assignee = user + + expect(subject.assignee_or_author?(user)).to eq(true) + end + + it 'returns true for a user that is the author of a merge request' do + subject.author = user + + expect(subject.assignee_or_author?(user)).to eq(true) + end + + it 'returns false for a user that is not the assignee or author' do + expect(subject.assignee_or_author?(user)).to eq(false) + end + end + describe '#cache_merge_request_closes_issues!' do before do subject.project.team << [subject.author, :developer] @@ -295,16 +376,6 @@ describe MergeRequest, models: true do end end - describe '#is_being_reassigned?' do - it 'returns true if the merge_request assignee has changed' do - subject.assignee = create(:user) - expect(subject.is_being_reassigned?).to be_truthy - end - it 'returns false if the merge request assignee has not changed' do - expect(subject.is_being_reassigned?).to be_falsey - end - end - describe '#for_fork?' do it 'returns true if the merge request is for a fork' do subject.source_project = build_stubbed(:empty_project, namespace: create(:group)) @@ -1554,4 +1625,23 @@ describe MergeRequest, models: true do expect(subject.has_no_commits?).to be_truthy end end + + describe '#merge_request_diff_for' do + subject { create(:merge_request, importing: true) } + let!(:merge_request_diff1) { subject.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + let!(:merge_request_diff2) { subject.merge_request_diffs.create(head_commit_sha: nil) } + let!(:merge_request_diff3) { subject.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + + context 'with diff refs' do + it 'returns the diffs' do + expect(subject.merge_request_diff_for(merge_request_diff1.diff_refs)).to eq(merge_request_diff1) + end + end + + context 'with a commit SHA' do + it 'returns the diffs' do + expect(subject.merge_request_diff_for(merge_request_diff3.head_commit_sha)).to eq(merge_request_diff3) + end + end + end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index e406d0a16bd..8624616316c 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -34,6 +34,13 @@ describe Namespace, models: true do let(:group) { build(:group, :nested, path: 'tree') } it { expect(group).not_to be_valid } + + it 'rejects nested paths' do + parent = create(:group, :nested, path: 'environments') + namespace = build(:project, path: 'folders', namespace: parent) + + expect(namespace).not_to be_valid + end end context 'top-level group' do @@ -47,6 +54,7 @@ describe Namespace, models: true do describe "Respond to" do it { is_expected.to respond_to(:human_name) } it { is_expected.to respond_to(:to_param) } + it { is_expected.to respond_to(:has_parent?) } end describe '#to_param' do diff --git a/spec/models/network/graph_spec.rb b/spec/models/network/graph_spec.rb index 46b36e11c23..0fe8a591a45 100644 --- a/spec/models/network/graph_spec.rb +++ b/spec/models/network/graph_spec.rb @@ -10,17 +10,17 @@ describe Network::Graph, models: true do expect(graph.notes).to eq( { note_on_commit.commit_id => 1 } ) end - describe "#commits" do + describe '#commits' do let(:graph) { described_class.new(project, 'refs/heads/master', project.repository.commit, nil) } - it "returns a list of commits" do + it 'returns a list of commits' do commits = graph.commits expect(commits).not_to be_empty expect(commits).to all( be_kind_of(Network::Commit) ) end - it "sorts the commits by commit date (descending)" do + it 'it the commits by commit date (descending)' do # Remove duplicate timestamps because they make it harder to # assert that the commits are sorted as expected. commits = graph.commits.uniq(&:date) @@ -29,5 +29,20 @@ describe Network::Graph, models: true do expect(commits).not_to be_empty expect(commits.map(&:id)).to eq(sorted_commits.map(&:id)) end + + it 'sorts children before parents for commits with the same timestamp' do + commits_by_time = graph.commits.group_by(&:date) + + commits_by_time.each do |time, commits| + commit_ids = commits.map(&:id) + + commits.each_with_index do |commit, index| + parent_indexes = commit.parent_ids.map { |parent_id| commit_ids.find_index(parent_id) }.compact + + # All parents of the current commit should appear after it + expect(parent_indexes).to all( be > index ) + end + end + end end end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 557ea97b008..7a01cef9b4b 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -272,9 +272,9 @@ describe Note, models: true do Gitlab::Diff::Position.new( old_path: "files/ruby/popen.rb", new_path: "files/ruby/popen.rb", - old_line: 16, - new_line: 22, - diff_refs: merge_request.diff_refs + old_line: nil, + new_line: 13, + diff_refs: project.commit(sample_commit.id).diff_refs ) end @@ -288,26 +288,78 @@ describe Note, models: true do ) end - subject { merge_request.notes.grouped_diff_discussions } + context 'active diff discussions' do + subject { merge_request.notes.grouped_diff_discussions } - it "includes active discussions" do - discussions = subject.values.flatten + it "includes active discussions" do + discussions = subject.values.flatten - expect(discussions.count).to eq(2) - expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id]) - expect(discussions.all?(&:active?)).to be true + expect(discussions.count).to eq(2) + expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id]) + expect(discussions.all?(&:active?)).to be true - expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2]) - expect(discussions.last.notes).to eq([active_diff_note3]) - end + expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2]) + expect(discussions.last.notes).to eq([active_diff_note3]) + end - it "doesn't include outdated discussions" do - expect(subject.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + it "doesn't include outdated discussions" do + expect(subject.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + end + + it "groups the discussions by line code" do + expect(subject[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) + expect(subject[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) + end end - it "groups the discussions by line code" do - expect(subject[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) - expect(subject[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) + context 'diff discussions for older diff refs' do + subject { merge_request.notes.grouped_diff_discussions(diff_refs) } + + context 'for diff refs a discussion was created at' do + let(:diff_refs) { active_position2.diff_refs } + + it "includes discussions that were created then" do + discussions = subject.values.flatten + + expect(discussions.count).to eq(1) + + discussion = discussions.first + + expect(discussion.id).to eq(active_diff_note3.discussion_id) + expect(discussion.active?).to be true + expect(discussion.active?(diff_refs)).to be false + expect(discussion.created_at_diff?(diff_refs)).to be true + + expect(discussion.notes).to eq([active_diff_note3]) + end + + it "groups the discussions by original line code" do + expect(subject[active_diff_note3.original_line_code].first.id).to eq(active_diff_note3.discussion_id) + end + end + + context 'for diff refs a discussion was last active at' do + let(:diff_refs) { outdated_position.diff_refs } + + it "includes discussions that were last active" do + discussions = subject.values.flatten + + expect(discussions.count).to eq(1) + + discussion = discussions.first + + expect(discussion.id).to eq(outdated_diff_note1.discussion_id) + expect(discussion.active?).to be false + expect(discussion.active?(diff_refs)).to be true + expect(discussion.created_at_diff?(diff_refs)).to be true + + expect(discussion.notes).to eq([outdated_diff_note1, outdated_diff_note2]) + end + + it "groups the discussions by line code" do + expect(subject[outdated_diff_note1.line_code].first.id).to eq(outdated_diff_note1.discussion_id) + end + end end end diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb index ec5c6c5e0ed..e005be42b0d 100644 --- a/spec/models/project_services/chat_message/pipeline_message_spec.rb +++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb @@ -4,6 +4,7 @@ describe ChatMessage::PipelineMessage do subject { described_class.new(args) } let(:user) { { name: 'hacker' } } + let(:duration) { 7210 } let(:args) do { object_attributes: { @@ -26,7 +27,6 @@ describe ChatMessage::PipelineMessage do context 'pipeline succeeded' do let(:status) { 'success' } let(:color) { 'good' } - let(:duration) { 10 } let(:message) { build_message('passed') } it 'returns a message with information about succeeded build' do @@ -39,7 +39,6 @@ describe ChatMessage::PipelineMessage do context 'pipeline failed' do let(:status) { 'failed' } let(:color) { 'danger' } - let(:duration) { 10 } let(:message) { build_message } it 'returns a message with information about failed build' do @@ -64,7 +63,7 @@ describe ChatMessage::PipelineMessage do "<http://example.gitlab.com|project_name>:" \ " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ " of <http://example.gitlab.com/commits/develop|develop> branch" \ - " by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}" + " by #{name} #{status_text} in 02:00:10" end end @@ -76,7 +75,6 @@ describe ChatMessage::PipelineMessage do context 'pipeline succeeded' do let(:status) { 'success' } let(:color) { 'good' } - let(:duration) { 10 } let(:message) { build_markdown_message('passed') } it 'returns a message with information about succeeded build' do @@ -85,7 +83,7 @@ describe ChatMessage::PipelineMessage do expect(subject.activity).to eq({ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker passed', subtitle: 'in [project_name](http://example.gitlab.com)', - text: 'in 10 seconds', + text: 'in 02:00:10', image: '' }) end @@ -94,7 +92,6 @@ describe ChatMessage::PipelineMessage do context 'pipeline failed' do let(:status) { 'failed' } let(:color) { 'danger' } - let(:duration) { 10 } let(:message) { build_markdown_message } it 'returns a message with information about failed build' do @@ -103,7 +100,7 @@ describe ChatMessage::PipelineMessage do expect(subject.activity).to eq({ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker failed', subtitle: 'in [project_name](http://example.gitlab.com)', - text: 'in 10 seconds', + text: 'in 02:00:10', image: '' }) end @@ -118,7 +115,7 @@ describe ChatMessage::PipelineMessage do expect(subject.activity).to eq({ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by API failed', subtitle: 'in [project_name](http://example.gitlab.com)', - text: 'in 10 seconds', + text: 'in 02:00:10', image: '' }) end @@ -129,7 +126,7 @@ describe ChatMessage::PipelineMessage do "[project_name](http://example.gitlab.com):" \ " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ " of [develop](http://example.gitlab.com/commits/develop)" \ - " branch by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}" + " branch by #{name} #{status_text} in 02:00:10" end end end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index d15079b686b..f3126bc1e57 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -94,7 +94,7 @@ describe PrometheusService, models: true, caching: true do [404, 500].each do |status| context "when Prometheus responds with #{status}" do before do - stub_all_prometheus_requests(environment.slug, status: status, body: 'QUERY FAILED!') + stub_all_prometheus_requests(environment.slug, status: status, body: "QUERY FAILED!") end it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index d9244657953..2fc8ffed80a 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -253,6 +253,34 @@ describe Project, models: true do expect(new_project.errors.full_messages.first).to eq('The project is still being deleted. Please try again later.') end end + + describe 'path validation' do + it 'allows paths reserved on the root namespace' do + project = build(:project, path: 'api') + + expect(project).to be_valid + end + + it 'rejects paths reserved on another level' do + project = build(:project, path: 'tree') + + expect(project).not_to be_valid + end + + it 'rejects nested paths' do + parent = create(:group, :nested, path: 'environments') + project = build(:project, path: 'folders', namespace: parent) + + expect(project).not_to be_valid + end + + it 'allows a reserved group name' do + parent = create(:group) + project = build(:project, path: 'avatar', namespace: parent) + + expect(project).to be_valid + end + end end describe 'default_scope' do @@ -783,12 +811,9 @@ describe Project, models: true do context 'when avatar file is uploaded' do let(:project) { create(:empty_project, :with_avatar) } + let(:avatar_path) { "/uploads/project/avatar/#{project.id}/dk.png" } - it 'creates a correct avatar path' do - avatar_path = "/uploads/project/avatar/#{project.id}/dk.png" - - expect(project.avatar_url).to eq("http://#{Gitlab.config.gitlab.host}#{avatar_path}") - end + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end context 'When avatar file in git' do @@ -796,9 +821,7 @@ describe Project, models: true do allow(project).to receive(:avatar_in_git) { true } end - let(:avatar_path) do - "/#{project.full_path}/avatar" - end + let(:avatar_path) { "/#{project.full_path}/avatar" } it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end @@ -1878,4 +1901,31 @@ describe Project, models: true do expect(project.pipeline_status).to be_loaded end end + + describe '#append_or_update_attribute' do + let(:project) { create(:project) } + + it 'shows full error updating an invalid MR' do + error_message = 'Failed to replace merge_requests because one or more of the new records could not be saved.'\ + ' Validate fork Source project is not a fork of the target project' + + expect { project.append_or_update_attribute(:merge_requests, [create(:merge_request)]) }. + to raise_error(ActiveRecord::RecordNotSaved, error_message) + end + + it 'updates the project succesfully' do + merge_request = create(:merge_request, target_project: project, source_project: project) + + expect { project.append_or_update_attribute(:merge_requests, [merge_request]) }. + not_to raise_error + end + end + + describe '#last_repository_updated_at' do + it 'sets to created_at upon creation' do + project = create(:empty_project, created_at: 2.hours.ago) + + expect(project.last_repository_updated_at.to_i).to eq(project.created_at.to_i) + end + end end diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index b5b9cd024b0..969e9f7a130 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -213,9 +213,12 @@ describe ProjectWiki, models: true do end it 'updates project activity' do - expect(subject).to receive(:update_project_activity) - subject.create_page('Test Page', 'This is content') + + project.reload + + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) end end @@ -240,9 +243,12 @@ describe ProjectWiki, models: true do end it 'updates project activity' do - expect(subject).to receive(:update_project_activity) - subject.update_page(@gollum_page, 'Yet more content', :markdown, 'Updated page again') + + project.reload + + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) end end @@ -258,9 +264,12 @@ describe ProjectWiki, models: true do end it 'updates project activity' do - expect(subject).to receive(:update_project_activity) - subject.delete_page(@page) + + project.reload + + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) end end diff --git a/spec/models/redirect_route_spec.rb b/spec/models/redirect_route_spec.rb new file mode 100644 index 00000000000..71827421dd7 --- /dev/null +++ b/spec/models/redirect_route_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +describe RedirectRoute, models: true do + let(:group) { create(:group) } + let!(:redirect_route) { group.redirect_routes.create(path: 'gitlabb') } + + describe 'relationships' do + it { is_expected.to belong_to(:source) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:source) } + it { is_expected.to validate_presence_of(:path) } + it { is_expected.to validate_uniqueness_of(:path) } + end + + describe '.matching_path_and_descendants' do + let!(:redirect2) { group.redirect_routes.create(path: 'gitlabb/test') } + let!(:redirect3) { group.redirect_routes.create(path: 'gitlabb/test/foo') } + let!(:redirect4) { group.redirect_routes.create(path: 'gitlabb/test/foo/bar') } + let!(:redirect5) { group.redirect_routes.create(path: 'gitlabb/test/baz') } + + it 'returns correct routes' do + expect(RedirectRoute.matching_path_and_descendants('gitlabb/test')).to match_array([redirect2, redirect3, redirect4, redirect5]) + end + end +end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 5216764a82d..dd6514b3b50 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1098,21 +1098,33 @@ describe Repository, models: true do end describe '#merge' do - it 'merges the code and return the commit id' do + let(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) } + + let(:commit_options) do + author = repository.user_to_committer(user) + { message: 'Test \r\n\r\n message', committer: author, author: author } + end + + it 'merges the code and returns the commit id' do expect(merge_commit).to be_present expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present end it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do - merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) - - merge_commit_id = repository.merge(user, - merge_request.diff_head_sha, - merge_request, - commit_options) + merge_commit_id = merge(repository, user, merge_request, commit_options) expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id) end + + it 'removes carriage returns from commit message' do + merge_commit_id = merge(repository, user, merge_request, commit_options) + + expect(repository.commit(merge_commit_id).message).to eq(commit_options[:message].delete("\r")) + end + + def merge(repository, user, merge_request, options = {}) + repository.merge(user, merge_request.diff_head_sha, merge_request, options) + end end describe '#revert' do diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index 171a51fcc5b..c1fe1b06c52 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -1,19 +1,43 @@ require 'spec_helper' describe Route, models: true do - let!(:group) { create(:group, path: 'git_lab', name: 'git_lab') } - let!(:route) { group.route } + let(:group) { create(:group, path: 'git_lab', name: 'git_lab') } + let(:route) { group.route } describe 'relationships' do it { is_expected.to belong_to(:source) } end describe 'validations' do + before { route } it { is_expected.to validate_presence_of(:source) } it { is_expected.to validate_presence_of(:path) } it { is_expected.to validate_uniqueness_of(:path) } end + describe 'callbacks' do + context 'after update' do + it 'calls #create_redirect_for_old_path' do + expect(route).to receive(:create_redirect_for_old_path) + route.update_attributes(path: 'foo') + end + + it 'calls #delete_conflicting_redirects' do + expect(route).to receive(:delete_conflicting_redirects) + route.update_attributes(path: 'foo') + end + end + + context 'after create' do + it 'calls #delete_conflicting_redirects' do + route.destroy + new_route = Route.new(source: group, path: group.path) + expect(new_route).to receive(:delete_conflicting_redirects) + new_route.save! + end + end + end + describe '.inside_path' do let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) } let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) } @@ -37,7 +61,7 @@ describe Route, models: true do context 'when route name is set' do before { route.update_attributes(path: 'bar') } - it "updates children routes with new path" do + it 'updates children routes with new path' do expect(described_class.exists?(path: 'bar')).to be_truthy expect(described_class.exists?(path: 'bar/test')).to be_truthy expect(described_class.exists?(path: 'bar/test/foo')).to be_truthy @@ -56,10 +80,24 @@ describe Route, models: true do expect(route.update_attributes(path: 'bar')).to be_truthy end end + + context 'when conflicting redirects exist' do + let!(:conflicting_redirect1) { route.create_redirect('bar/test') } + let!(:conflicting_redirect2) { route.create_redirect('bar/test/foo') } + let!(:conflicting_redirect3) { route.create_redirect('gitlab-org') } + + it 'deletes the conflicting redirects' do + route.update_attributes(path: 'bar') + + expect(RedirectRoute.exists?(path: 'bar/test')).to be_falsey + expect(RedirectRoute.exists?(path: 'bar/test/foo')).to be_falsey + expect(RedirectRoute.exists?(path: 'gitlab-org')).to be_truthy + end + end end context 'name update' do - it "updates children routes with new path" do + it 'updates children routes with new path' do route.update_attributes(name: 'bar') expect(described_class.exists?(name: 'bar')).to be_truthy @@ -77,4 +115,72 @@ describe Route, models: true do end end end + + describe '#create_redirect_for_old_path' do + context 'if the path changed' do + it 'creates a RedirectRoute for the old path' do + redirect_scope = route.source.redirect_routes.where(path: 'git_lab') + expect(redirect_scope.exists?).to be_falsey + route.path = 'new-path' + route.save! + expect(redirect_scope.exists?).to be_truthy + end + end + end + + describe '#create_redirect' do + it 'creates a RedirectRoute with the same source' do + redirect_route = route.create_redirect('foo') + expect(redirect_route).to be_a(RedirectRoute) + expect(redirect_route).to be_persisted + expect(redirect_route.source).to eq(route.source) + expect(redirect_route.path).to eq('foo') + end + end + + describe '#delete_conflicting_redirects' do + context 'when a redirect route with the same path exists' do + let!(:redirect1) { route.create_redirect(route.path) } + + it 'deletes the redirect' do + route.delete_conflicting_redirects + expect(route.conflicting_redirects).to be_empty + end + + context 'when redirect routes with paths descending from the route path exists' do + let!(:redirect2) { route.create_redirect("#{route.path}/foo") } + let!(:redirect3) { route.create_redirect("#{route.path}/foo/bar") } + let!(:redirect4) { route.create_redirect("#{route.path}/baz/quz") } + let!(:other_redirect) { route.create_redirect("other") } + + it 'deletes all redirects with paths that descend from the route path' do + route.delete_conflicting_redirects + expect(route.conflicting_redirects).to be_empty + end + end + end + end + + describe '#conflicting_redirects' do + context 'when a redirect route with the same path exists' do + let!(:redirect1) { route.create_redirect(route.path) } + + it 'returns the redirect route' do + expect(route.conflicting_redirects).to be_an(ActiveRecord::Relation) + expect(route.conflicting_redirects).to match_array([redirect1]) + end + + context 'when redirect routes with paths descending from the route path exists' do + let!(:redirect2) { route.create_redirect("#{route.path}/foo") } + let!(:redirect3) { route.create_redirect("#{route.path}/foo/bar") } + let!(:redirect4) { route.create_redirect("#{route.path}/baz/quz") } + let!(:other_redirect) { route.create_redirect("other") } + + it 'returns the redirect routes' do + expect(route.conflicting_redirects).to be_an(ActiveRecord::Relation) + expect(route.conflicting_redirects).to match_array([redirect1, redirect2, redirect3, redirect4]) + end + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0bcebc27598..63e71f5ff2f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -97,6 +97,18 @@ describe User, models: true do expect(user.errors.values).to eq [['dashboard is a reserved name']] end + it 'allows child names' do + user = build(:user, username: 'avatar') + + expect(user).to be_valid + end + + it 'allows wildcard names' do + user = build(:user, username: 'blob') + + expect(user).to be_valid + end + it 'validates uniqueness' do expect(subject).to validate_uniqueness_of(:username).case_insensitive end @@ -837,6 +849,65 @@ describe User, models: true do end end + describe '.find_by_full_path' do + let!(:user) { create(:user) } + + context 'with a route matching the given path' do + let!(:route) { user.namespace.route } + + it 'returns the user' do + expect(User.find_by_full_path(route.path)).to eq(user) + end + + it 'is case-insensitive' do + expect(User.find_by_full_path(route.path.upcase)).to eq(user) + expect(User.find_by_full_path(route.path.downcase)).to eq(user) + end + end + + context 'with a redirect route matching the given path' do + let!(:redirect_route) { user.namespace.redirect_routes.create(path: 'foo') } + + context 'without the follow_redirects option' do + it 'returns nil' do + expect(User.find_by_full_path(redirect_route.path)).to eq(nil) + end + end + + context 'with the follow_redirects option set to true' do + it 'returns the user' do + expect(User.find_by_full_path(redirect_route.path, follow_redirects: true)).to eq(user) + end + + it 'is case-insensitive' do + expect(User.find_by_full_path(redirect_route.path.upcase, follow_redirects: true)).to eq(user) + expect(User.find_by_full_path(redirect_route.path.downcase, follow_redirects: true)).to eq(user) + end + end + end + + context 'without a route or a redirect route matching the given path' do + context 'without the follow_redirects option' do + it 'returns nil' do + expect(User.find_by_full_path('unknown')).to eq(nil) + end + end + context 'with the follow_redirects option set to true' do + it 'returns nil' do + expect(User.find_by_full_path('unknown', follow_redirects: true)).to eq(nil) + end + end + end + + context 'with a group route matching the given path' do + let!(:group) { create(:group, path: 'group_path') } + + it 'returns nil' do + expect(User.find_by_full_path('group_path')).to eq(nil) + end + end + end + describe 'all_ssh_keys' do it { is_expected.to have_many(:keys).dependent(:destroy) } @@ -862,6 +933,17 @@ describe User, models: true do end end + describe '#avatar_url' do + let(:user) { create(:user, :with_avatar) } + subject { user.avatar_url } + + context 'when avatar file is uploaded' do + let(:avatar_path) { "/uploads/user/avatar/#{user.id}/dk.png" } + + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + end + end + describe '#requires_ldap_check?' do let(:user) { User.new } @@ -1651,4 +1733,12 @@ describe User, models: true do expect(User.active.count).to eq(1) end end + + describe 'preferred language' do + it 'is English by default' do + user = create(:user) + + expect(user.preferred_language).to eq('en') + end + end end diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb index 9a870b7fda1..4a07c864428 100644 --- a/spec/policies/issue_policy_spec.rb +++ b/spec/policies/issue_policy_spec.rb @@ -15,7 +15,7 @@ describe IssuePolicy, models: true do context 'a private project' do let(:non_member) { create(:user) } let(:project) { create(:empty_project, :private) } - let(:issue) { create(:issue, project: project, assignee: assignee, author: author) } + let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) } let(:issue_no_assignee) { create(:issue, project: project) } before do @@ -69,7 +69,7 @@ describe IssuePolicy, models: true do end context 'with confidential issues' do - let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) } + let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) } let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow non-members to read confidential issues' do @@ -110,7 +110,7 @@ describe IssuePolicy, models: true do context 'a public project' do let(:project) { create(:empty_project, :public) } - let(:issue) { create(:issue, project: project, assignee: assignee, author: author) } + let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) } let(:issue_no_assignee) { create(:issue, project: project) } before do @@ -157,7 +157,7 @@ describe IssuePolicy, models: true do end context 'with confidential issues' do - let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) } + let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) } let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow guests to read confidential issues' do diff --git a/spec/policies/personal_snippet_policy_spec.rb b/spec/policies/personal_snippet_policy_spec.rb new file mode 100644 index 00000000000..58aa1145c9e --- /dev/null +++ b/spec/policies/personal_snippet_policy_spec.rb @@ -0,0 +1,141 @@ +require 'spec_helper' + +describe PersonalSnippetPolicy, models: true do + let(:regular_user) { create(:user) } + let(:external_user) { create(:user, :external) } + let(:admin_user) { create(:user, :admin) } + + let(:author_permissions) do + [ + :update_personal_snippet, + :admin_personal_snippet, + :destroy_personal_snippet + ] + end + + def permissions(user) + described_class.abilities(user, snippet).to_set + end + + context 'public snippet' do + let(:snippet) { create(:personal_snippet, :public) } + + context 'no user' do + subject { permissions(nil) } + + it do + is_expected.to include(:read_personal_snippet) + is_expected.not_to include(:comment_personal_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'regular user' do + subject { permissions(regular_user) } + + it do + is_expected.to include(:read_personal_snippet) + is_expected.to include(:comment_personal_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'author' do + subject { permissions(snippet.author) } + + it do + is_expected.to include(:read_personal_snippet) + is_expected.to include(:comment_personal_snippet) + is_expected.to include(*author_permissions) + end + end + end + + context 'internal snippet' do + let(:snippet) { create(:personal_snippet, :internal) } + + context 'no user' do + subject { permissions(nil) } + + it do + is_expected.not_to include(:read_personal_snippet) + is_expected.not_to include(:comment_personal_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'regular user' do + subject { permissions(regular_user) } + + it do + is_expected.to include(:read_personal_snippet) + is_expected.to include(:comment_personal_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'external user' do + subject { permissions(external_user) } + + it do + is_expected.not_to include(:read_personal_snippet) + is_expected.not_to include(:comment_personal_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'snippet author' do + subject { permissions(snippet.author) } + + it do + is_expected.to include(:read_personal_snippet) + is_expected.to include(:comment_personal_snippet) + is_expected.to include(*author_permissions) + end + end + end + + context 'private snippet' do + let(:snippet) { create(:project_snippet, :private) } + + context 'no user' do + subject { permissions(nil) } + + it do + is_expected.not_to include(:read_personal_snippet) + is_expected.not_to include(:comment_personal_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'regular user' do + subject { permissions(regular_user) } + + it do + is_expected.not_to include(:read_personal_snippet) + is_expected.not_to include(:comment_personal_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'external user' do + subject { permissions(external_user) } + + it do + is_expected.not_to include(:read_personal_snippet) + is_expected.not_to include(:comment_personal_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'snippet author' do + subject { permissions(snippet.author) } + + it do + is_expected.to include(:read_personal_snippet) + is_expected.to include(:comment_personal_snippet) + is_expected.to include(*author_permissions) + end + end + end +end diff --git a/spec/requests/api/helpers/internal_helpers_spec.rb b/spec/requests/api/helpers/internal_helpers_spec.rb deleted file mode 100644 index f5265ea60ff..00000000000 --- a/spec/requests/api/helpers/internal_helpers_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'spec_helper' - -describe ::API::Helpers::InternalHelpers do - include ::API::Helpers::InternalHelpers - - describe '.clean_project_path' do - project = 'namespace/project' - namespaced = File.join('namespace2', project) - - { - File.join(Dir.pwd, project) => project, - File.join(Dir.pwd, namespaced) => namespaced, - project => project, - namespaced => namespaced, - project + '.git' => project, - namespaced + '.git' => namespaced, - "/" + project => project, - "/" + namespaced => namespaced, - }.each do |project_path, expected| - context project_path do - # Relative and absolute storage paths, with and without trailing / - ['.', './', Dir.pwd, Dir.pwd + '/'].each do |storage_path| - context "storage path is #{storage_path}" do - subject { clean_project_path(project_path, [{ 'path' => storage_path }]) } - - it { is_expected.to eq(expected) } - end - end - end - end - end -end diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index 06c8eb1d0b7..ed392acc607 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe API::Helpers do include API::APIGuard::HelperMethods - include API::Helpers + include described_class include SentryHelper let(:user) { create(:user) } diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 429f1a4e375..2ceb4648ece 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -180,6 +180,7 @@ describe API::Internal do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("wiki-#{project.id}") expect(user).not_to have_an_activity_record end end @@ -191,6 +192,7 @@ describe API::Internal do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("wiki-#{project.id}") expect(user).to have_an_activity_record end end @@ -202,6 +204,7 @@ describe API::Internal do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("project-#{project.id}") expect(user).to have_an_activity_record end end @@ -213,6 +216,7 @@ describe API::Internal do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("project-#{project.id}") expect(user).not_to have_an_activity_record end @@ -223,6 +227,7 @@ describe API::Internal do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("project-#{project.id}") end end @@ -233,6 +238,7 @@ describe API::Internal do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("project-#{project.id}") end end end @@ -444,18 +450,39 @@ describe API::Internal do expect(json_response).to eq([]) end + + context 'with a gl_repository parameter' do + let(:gl_repository) { "project-#{project.id}" } + + it 'returns link to create new merge request' do + get api("/internal/merge_request_urls?gl_repository=#{gl_repository}&changes=#{changes}"), secret_token: secret_token + + expect(json_response).to match [{ + "branch_name" => "new_branch", + "url" => "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch", + "new_merge_request" => true + }] + end + end end describe 'POST /notify_post_receive' do let(:valid_params) do - { repo_path: project.repository.path, secret_token: secret_token } + { project: project.repository.path, secret_token: secret_token } + end + + let(:valid_wiki_params) do + { project: project.wiki.repository.path, secret_token: secret_token } end before do allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true) end - it "calls the Gitaly client if it's enabled" do + it "calls the Gitaly client with the project's repository" do + expect(Gitlab::GitalyClient::Notifications). + to receive(:new).with(gitlab_git_repository_with(path: project.repository.path)). + and_call_original expect_any_instance_of(Gitlab::GitalyClient::Notifications). to receive(:post_receive) @@ -464,6 +491,18 @@ describe API::Internal do expect(response).to have_http_status(200) end + it "calls the Gitaly client with the wiki's repository if it's a wiki" do + expect(Gitlab::GitalyClient::Notifications). + to receive(:new).with(gitlab_git_repository_with(path: project.wiki.repository.path)). + and_call_original + expect_any_instance_of(Gitlab::GitalyClient::Notifications). + to receive(:post_receive) + + post api("/internal/notify_post_receive"), valid_wiki_params + + expect(response).to have_http_status(200) + end + it "returns 500 if the gitaly call fails" do expect_any_instance_of(Gitlab::GitalyClient::Notifications). to receive(:post_receive).and_raise(GRPC::Unavailable) @@ -472,6 +511,40 @@ describe API::Internal do expect(response).to have_http_status(500) end + + context 'with a gl_repository parameter' do + let(:valid_params) do + { gl_repository: "project-#{project.id}", secret_token: secret_token } + end + + let(:valid_wiki_params) do + { gl_repository: "wiki-#{project.id}", secret_token: secret_token } + end + + it "calls the Gitaly client with the project's repository" do + expect(Gitlab::GitalyClient::Notifications). + to receive(:new).with(gitlab_git_repository_with(path: project.repository.path)). + and_call_original + expect_any_instance_of(Gitlab::GitalyClient::Notifications). + to receive(:post_receive) + + post api("/internal/notify_post_receive"), valid_params + + expect(response).to have_http_status(200) + end + + it "calls the Gitaly client with the wiki's repository if it's a wiki" do + expect(Gitlab::GitalyClient::Notifications). + to receive(:new).with(gitlab_git_repository_with(path: project.wiki.repository.path)). + and_call_original + expect_any_instance_of(Gitlab::GitalyClient::Notifications). + to receive(:post_receive) + + post api("/internal/notify_post_receive"), valid_wiki_params + + expect(response).to have_http_status(200) + end + end end def project_with_repo_path(path) diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 3ca13111acb..da2b56c040b 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -19,7 +19,7 @@ describe API::Issues do let!(:closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: project, state: :closed, milestone: milestone, @@ -31,14 +31,14 @@ describe API::Issues do :confidential, project: project, author: author, - assignee: assignee, + assignees: [assignee], created_at: generate(:past_time), updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: project, milestone: milestone, created_at: generate(:past_time), @@ -265,7 +265,7 @@ describe API::Issues do let!(:group_closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: group_project, state: :closed, milestone: group_milestone, @@ -276,13 +276,13 @@ describe API::Issues do :confidential, project: group_project, author: author, - assignee: assignee, + assignees: [assignee], updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: group_project, milestone: group_milestone, updated_at: 1.hour.ago, @@ -687,6 +687,7 @@ describe API::Issues do expect(json_response['updated_at']).to be_present expect(json_response['labels']).to eq(issue.label_names) expect(json_response['milestone']).to be_a Hash + expect(json_response['assignees']).to be_a Array expect(json_response['assignee']).to be_a Hash expect(json_response['author']).to be_a Hash expect(json_response['confidential']).to be_falsy @@ -759,15 +760,41 @@ describe API::Issues do end describe "POST /projects/:id/issues" do + context 'support for deprecated assignee_id' do + it 'creates a new project issue' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', assignee_id: user2.id + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + end + + context 'CE restrictions' do + it 'creates a new project issue with no more than one assignee' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', assignee_ids: [user2.id, guest.id] + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignees'].count).to eq(1) + end + end + it 'creates a new project issue' do post api("/projects/#{project.id}/issues", user), - title: 'new issue', labels: 'label, label2' + title: 'new issue', labels: 'label, label2', weight: 3, + assignee_ids: [user2.id] expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) end it 'creates a new confidential project issue' do @@ -1057,6 +1084,57 @@ describe API::Issues do end end + describe 'PUT /projects/:id/issues/:issue_iid to update assignee' do + context 'support for deprecated assignee_id' do + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_id: 0 + + expect(response).to have_http_status(200) + + expect(json_response['assignee']).to be_nil + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_id: user2.id + + expect(response).to have_http_status(200) + + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [0] + + expect(response).to have_http_status(200) + + expect(json_response['assignees']).to be_empty + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [user2.id] + + expect(response).to have_http_status(200) + + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + + context 'CE restrictions' do + it 'updates an issue with several assignee but only one has been applied' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [user2.id, guest.id] + + expect(response).to have_http_status(200) + + expect(json_response['assignees'].size).to eq(1) + end + end + end + describe 'PUT /projects/:id/issues/:issue_iid to update labels' do let!(:label) { create(:label, title: 'dummy', project: project) } let!(:label_link) { create(:label_link, label: label, target: issue) } diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index 762345cd41c..f9e5316b3de 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -24,6 +24,245 @@ describe API::Pipelines do expect(json_response.first['id']).to eq pipeline.id expect(json_response.first.keys).to contain_exactly(*%w[id sha ref status]) end + + context 'when parameter is passed' do + %w[running pending].each do |target| + context "when scope is #{target}" do + before do + create(:ci_pipeline, project: project, status: target) + end + + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), scope: target + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + json_response.each { |r| expect(r['status']).to eq(target) } + end + end + end + + context 'when scope is finished' do + before do + create(:ci_pipeline, project: project, status: 'success') + create(:ci_pipeline, project: project, status: 'failed') + create(:ci_pipeline, project: project, status: 'canceled') + end + + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), scope: 'finished' + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + json_response.each { |r| expect(r['status']).to be_in(%w[success failed canceled]) } + end + end + + context 'when scope is branches or tags' do + let!(:pipeline_branch) { create(:ci_pipeline, project: project) } + let!(:pipeline_tag) { create(:ci_pipeline, project: project, ref: 'v1.0.0', tag: true) } + + context 'when scope is branches' do + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), scope: 'branches' + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + expect(json_response.last['id']).to eq(pipeline_branch.id) + end + end + + context 'when scope is tags' do + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), scope: 'tags' + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + expect(json_response.last['id']).to eq(pipeline_tag.id) + end + end + end + + context 'when scope is invalid' do + it 'returns bad_request' do + get api("/projects/#{project.id}/pipelines", user), scope: 'invalid-scope' + + expect(response).to have_http_status(:bad_request) + end + end + + HasStatus::AVAILABLE_STATUSES.each do |target| + context "when status is #{target}" do + before do + create(:ci_pipeline, project: project, status: target) + exception_status = HasStatus::AVAILABLE_STATUSES - [target] + create(:ci_pipeline, project: project, status: exception_status.sample) + end + + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), status: target + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + json_response.each { |r| expect(r['status']).to eq(target) } + end + end + end + + context 'when status is invalid' do + it 'returns bad_request' do + get api("/projects/#{project.id}/pipelines", user), status: 'invalid-status' + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when ref is specified' do + before do + create(:ci_pipeline, project: project) + end + + context 'when ref exists' do + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), ref: 'master' + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + json_response.each { |r| expect(r['ref']).to eq('master') } + end + end + + context 'when ref does not exist' do + it 'returns empty' do + get api("/projects/#{project.id}/pipelines", user), ref: 'invalid-ref' + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_empty + end + end + end + + context 'when name is specified' do + let!(:pipeline) { create(:ci_pipeline, project: project, user: user) } + + context 'when name exists' do + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), name: user.name + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response.first['id']).to eq(pipeline.id) + end + end + + context 'when name does not exist' do + it 'returns empty' do + get api("/projects/#{project.id}/pipelines", user), name: 'invalid-name' + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_empty + end + end + end + + context 'when username is specified' do + let!(:pipeline) { create(:ci_pipeline, project: project, user: user) } + + context 'when username exists' do + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), username: user.username + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response.first['id']).to eq(pipeline.id) + end + end + + context 'when username does not exist' do + it 'returns empty' do + get api("/projects/#{project.id}/pipelines", user), username: 'invalid-username' + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_empty + end + end + end + + context 'when yaml_errors is specified' do + let!(:pipeline1) { create(:ci_pipeline, project: project, yaml_errors: 'Syntax error') } + let!(:pipeline2) { create(:ci_pipeline, project: project) } + + context 'when yaml_errors is true' do + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), yaml_errors: true + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response.first['id']).to eq(pipeline1.id) + end + end + + context 'when yaml_errors is false' do + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), yaml_errors: false + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response.first['id']).to eq(pipeline2.id) + end + end + + context 'when yaml_errors is invalid' do + it 'returns bad_request' do + get api("/projects/#{project.id}/pipelines", user), yaml_errors: 'invalid-yaml_errors' + + expect(response).to have_http_status(:bad_request) + end + end + end + + context 'when order_by and sort are specified' do + context 'when order_by user_id' do + let!(:pipeline) { create_list(:ci_pipeline, 2, project: project, user: create(:user)) } + + it 'sorts as user_id: :asc' do + get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'asc' + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + pipeline.sort_by { |p| p.user.id }.tap do |sorted_pipeline| + json_response.each_with_index { |r, i| expect(r['id']).to eq(sorted_pipeline[i].id) } + end + end + + context 'when sort is invalid' do + it 'returns bad_request' do + get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'invalid_sort' + + expect(response).to have_http_status(:bad_request) + end + end + end + + context 'when order_by is invalid' do + it 'returns bad_request' do + get api("/projects/#{project.id}/pipelines", user), order_by: 'lock_version', sort: 'asc' + + expect(response).to have_http_status(:bad_request) + end + end + end + end end context 'unauthorized user' do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index cc03d7a933b..ab70ce5cd2f 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -665,6 +665,20 @@ describe API::Projects do }) end + it "does not include statistics by default" do + get api("/projects/#{project.id}", user) + + expect(response).to have_http_status(200) + expect(json_response).not_to include 'statistics' + end + + it "includes statistics if requested" do + get api("/projects/#{project.id}", user), statistics: true + + expect(response).to have_http_status(200) + expect(json_response).to include 'statistics' + end + describe 'permissions' do context 'all projects' do before { project.team << [user, :master] } diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index ef5b10a1615..cc81922697a 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -14,7 +14,7 @@ describe API::V3::Issues do let!(:closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: project, state: :closed, milestone: milestone, @@ -26,14 +26,14 @@ describe API::V3::Issues do :confidential, project: project, author: author, - assignee: assignee, + assignees: [assignee], created_at: generate(:past_time), updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: project, milestone: milestone, created_at: generate(:past_time), @@ -247,7 +247,7 @@ describe API::V3::Issues do let!(:group_closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: group_project, state: :closed, milestone: group_milestone, @@ -258,13 +258,13 @@ describe API::V3::Issues do :confidential, project: group_project, author: author, - assignee: assignee, + assignees: [assignee], updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: group_project, milestone: group_milestone, updated_at: 1.hour.ago @@ -737,13 +737,14 @@ describe API::V3::Issues do describe "POST /projects/:id/issues" do it 'creates a new project issue' do post v3_api("/projects/#{project.id}/issues", user), - title: 'new issue', labels: 'label, label2' + title: 'new issue', labels: 'label, label2', assignee_id: assignee.id expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy + expect(json_response['assignee']['name']).to eq(assignee.name) end it 'creates a new confidential project issue' do @@ -1140,6 +1141,22 @@ describe API::V3::Issues do end end + describe 'PUT /projects/:id/issues/:issue_id to update assignee' do + it 'updates an issue with no assignee' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: 0 + + expect(response).to have_http_status(200) + expect(json_response['assignee']).to eq(nil) + end + + it 'updates an issue with assignee' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: user2.id + + expect(response).to have_http_status(200) + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + describe "DELETE /projects/:id/issues/:issue_id" do it "rejects a non member from deleting an issue" do delete v3_api("/projects/#{project.id}/issues/#{issue.id}", non_member) diff --git a/spec/requests/projects/artifacts_controller_spec.rb b/spec/requests/projects/artifacts_controller_spec.rb deleted file mode 100644 index d20866c0d44..00000000000 --- a/spec/requests/projects/artifacts_controller_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spec_helper' - -describe Projects::ArtifactsController do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - - let(:pipeline) do - create(:ci_pipeline, - project: project, - sha: project.commit.sha, - ref: project.default_branch, - status: 'success') - end - - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } - - describe 'GET /:project/builds/artifacts/:ref_name/browse?job=name' do - before do - project.team << [user, :developer] - - login_as(user) - end - - def path_from_ref( - ref = pipeline.ref, job = build.name, path = 'browse') - latest_succeeded_namespace_project_artifacts_path( - project.namespace, - project, - [ref, path].join('/'), - job: job) - end - - context 'cannot find the build' do - shared_examples 'not found' do - it { expect(response).to have_http_status(:not_found) } - end - - context 'has no such ref' do - before do - get path_from_ref('TAIL', build.name) - end - - it_behaves_like 'not found' - end - - context 'has no such build' do - before do - get path_from_ref(pipeline.ref, 'NOBUILD') - end - - it_behaves_like 'not found' - end - - context 'has no path' do - before do - get path_from_ref(pipeline.sha, build.name, '') - end - - it_behaves_like 'not found' - end - end - - context 'found the build and redirect' do - shared_examples 'redirect to the build' do - it 'redirects' do - path = browse_namespace_project_build_artifacts_path( - project.namespace, - project, - build) - - expect(response).to redirect_to(path) - end - end - - context 'with regular branch' do - before do - pipeline.update(ref: 'master', - sha: project.commit('master').sha) - - get path_from_ref('master') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name containing slash' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name and path containing slashes' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome', build.name, 'file/README.md') - end - - it 'redirects' do - path = file_namespace_project_build_artifacts_path( - project.namespace, - project, - build, - 'README.md') - - expect(response).to redirect_to(path) - end - end - end - end -end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 163df072cf6..50e96d56191 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'project routing' do before do allow(Project).to receive(:find_by_full_path).and_return(false) - allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq').and_return(true) + allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq', any_args).and_return(true) end # Shared examples for a resource inside a Project @@ -93,13 +93,13 @@ describe 'project routing' do end context 'name with dot' do - before { allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq.keys').and_return(true) } + before { allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq.keys', any_args).and_return(true) } it { expect(get('/gitlab/gitlabhq.keys')).to route_to('projects#show', namespace_id: 'gitlab', id: 'gitlabhq.keys') } end context 'with nested group' do - before { allow(Project).to receive(:find_by_full_path).with('gitlab/subgroup/gitlabhq').and_return(true) } + before { allow(Project).to receive(:find_by_full_path).with('gitlab/subgroup/gitlabhq', any_args).and_return(true) } it { expect(get('/gitlab/subgroup/gitlabhq')).to route_to('projects#show', namespace_id: 'gitlab/subgroup', id: 'gitlabhq') } end diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb new file mode 100644 index 00000000000..e73fbe190ca --- /dev/null +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe DeployKeyEntity do + include RequestAwareEntity + + let(:user) { create(:user) } + let(:project) { create(:empty_project, :internal)} + let(:project_private) { create(:empty_project, :private)} + let(:deploy_key) { create(:deploy_key) } + let!(:deploy_key_internal) { create(:deploy_keys_project, project: project, deploy_key: deploy_key) } + let!(:deploy_key_private) { create(:deploy_keys_project, project: project_private, deploy_key: deploy_key) } + + let(:entity) { described_class.new(deploy_key, user: user) } + + it 'returns deploy keys with projects a user can read' do + expected_result = { + id: deploy_key.id, + user_id: deploy_key.user_id, + title: deploy_key.title, + fingerprint: deploy_key.fingerprint, + can_push: deploy_key.can_push, + destroyed_when_orphaned: true, + almost_orphaned: false, + created_at: deploy_key.created_at, + updated_at: deploy_key.updated_at, + projects: [ + { + id: project.id, + name: project.name, + full_path: namespace_project_path(project.namespace, project), + full_name: project.full_name + } + ] + } + + expect(entity.as_json).to eq(expected_result) + end +end diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index 95eca5463eb..69355bcde42 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -3,25 +3,23 @@ require 'spec_helper' describe DeploymentEntity do let(:user) { create(:user) } let(:request) { double('request') } + let(:deployment) { create(:deployment) } + let(:entity) { described_class.new(deployment, request: request) } + subject { entity.as_json } before do allow(request).to receive(:user).and_return(user) end - let(:entity) do - described_class.new(deployment, request: request) - end - - let(:deployment) { create(:deployment) } - - subject { entity.as_json } - it 'exposes internal deployment id' do expect(subject).to include(:iid) end it 'exposes nested information about branch' do expect(subject[:ref][:name]).to eq 'master' - expect(subject[:ref][:ref_path]).not_to be_empty + end + + it 'exposes creation date' do + expect(subject).to include(:created_at) end end diff --git a/spec/serializers/label_serializer_spec.rb b/spec/serializers/label_serializer_spec.rb new file mode 100644 index 00000000000..c58c7da1f9e --- /dev/null +++ b/spec/serializers/label_serializer_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe LabelSerializer do + let(:user) { create(:user) } + + let(:serializer) do + described_class.new(user: user) + end + + subject { serializer.represent(resource) } + + describe '#represent' do + context 'when a single object is being serialized' do + let(:resource) { create(:label) } + + it 'serializes the label object' do + expect(subject[:id]).to eq resource.id + end + end + + context 'when multiple objects are being serialized' do + let(:num_labels) { 2 } + let(:resource) { create_list(:label, num_labels) } + + it 'serializes the array of labels' do + expect(subject.size).to eq(num_labels) + end + end + end + + describe '#represent_appearance' do + context 'when represents only appearance' do + let(:resource) { create(:label) } + + subject { serializer.represent_appearance(resource) } + + it 'serializes only attributes used for appearance' do + expect(subject.keys).to eq([:id, :title, :color, :text_color]) + expect(subject[:id]).to eq(resource.id) + expect(subject[:title]).to eq(resource.title) + expect(subject[:color]).to eq(resource.color) + expect(subject[:text_color]).to eq(resource.text_color) + end + end + end +end diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb index 4ab40d08432..0412b2d7741 100644 --- a/spec/serializers/stage_entity_spec.rb +++ b/spec/serializers/stage_entity_spec.rb @@ -47,5 +47,13 @@ describe StageEntity do it 'contains stage title' do expect(subject[:title]).to eq 'test: passed' end + + context 'when the jobs should be grouped' do + let(:entity) { described_class.new(stage, request: request, grouped: true) } + + it 'exposes the group key' do + expect(subject).to include :groups + end + end end end diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 7a1ac027310..5b1639ca0d6 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -4,11 +4,12 @@ describe Issuable::BulkUpdateService, services: true do let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } - def bulk_update(issues, extra_params = {}) + def bulk_update(issuables, extra_params = {}) bulk_update_params = extra_params - .reverse_merge(issuable_ids: Array(issues).map(&:id).join(',')) + .reverse_merge(issuable_ids: Array(issuables).map(&:id).join(',')) - Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute('issue') + type = Array(issuables).first.model_name.param_key + Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute(type) end describe 'close issues' do @@ -47,15 +48,15 @@ describe Issuable::BulkUpdateService, services: true do end end - describe 'updating assignee' do - let(:issue) { create(:issue, project: project, assignee: user) } + describe 'updating merge request assignee' do + let(:merge_request) { create(:merge_request, target_project: project, source_project: project, assignee: user) } context 'when the new assignee ID is a valid user' do it 'succeeds' do new_assignee = create(:user) project.team << [new_assignee, :developer] - result = bulk_update(issue, assignee_id: new_assignee.id) + result = bulk_update(merge_request, assignee_id: new_assignee.id) expect(result[:success]).to be_truthy expect(result[:count]).to eq(1) @@ -65,22 +66,59 @@ describe Issuable::BulkUpdateService, services: true do assignee = create(:user) project.team << [assignee, :developer] - expect { bulk_update(issue, assignee_id: assignee.id) } - .to change { issue.reload.assignee }.from(user).to(assignee) + expect { bulk_update(merge_request, assignee_id: assignee.id) } + .to change { merge_request.reload.assignee }.from(user).to(assignee) end end context "when the new assignee ID is #{IssuableFinder::NONE}" do it "unassigns the issues" do - expect { bulk_update(issue, assignee_id: IssuableFinder::NONE) } - .to change { issue.reload.assignee }.to(nil) + expect { bulk_update(merge_request, assignee_id: IssuableFinder::NONE) } + .to change { merge_request.reload.assignee }.to(nil) end end context 'when the new assignee ID is not present' do it 'does not unassign' do - expect { bulk_update(issue, assignee_id: nil) } - .not_to change { issue.reload.assignee } + expect { bulk_update(merge_request, assignee_id: nil) } + .not_to change { merge_request.reload.assignee } + end + end + end + + describe 'updating issue assignee' do + let(:issue) { create(:issue, project: project, assignees: [user]) } + + context 'when the new assignee ID is a valid user' do + it 'succeeds' do + new_assignee = create(:user) + project.team << [new_assignee, :developer] + + result = bulk_update(issue, assignee_ids: [new_assignee.id]) + + expect(result[:success]).to be_truthy + expect(result[:count]).to eq(1) + end + + it 'updates the assignee to the use ID passed' do + assignee = create(:user) + project.team << [assignee, :developer] + expect { bulk_update(issue, assignee_ids: [assignee.id]) } + .to change { issue.reload.assignees.first }.from(user).to(assignee) + end + end + + context "when the new assignee ID is #{IssuableFinder::NONE}" do + it "unassigns the issues" do + expect { bulk_update(issue, assignee_ids: [IssuableFinder::NONE.to_s]) } + .to change { issue.reload.assignees.count }.from(1).to(0) + end + end + + context 'when the new assignee ID is not present' do + it 'does not unassign' do + expect { bulk_update(issue, assignee_ids: []) } + .not_to change{ issue.reload.assignees } end end end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 7a54373963e..51840531711 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -4,7 +4,7 @@ describe Issues::CloseService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } let(:guest) { create(:user) } - let(:issue) { create(:issue, assignee: user2) } + let(:issue) { create(:issue, assignees: [user2]) } let(:project) { issue.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 80bfb731550..01edc46496d 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -6,10 +6,10 @@ describe Issues::CreateService, services: true do describe '#execute' do let(:issue) { described_class.new(project, user, opts).execute } + let(:assignee) { create(:user) } + let(:milestone) { create(:milestone, project: project) } context 'when params are valid' do - let(:assignee) { create(:user) } - let(:milestone) { create(:milestone, project: project) } let(:labels) { create_pair(:label, project: project) } before do @@ -20,7 +20,7 @@ describe Issues::CreateService, services: true do let(:opts) do { title: 'Awesome issue', description: 'please fix', - assignee_id: assignee.id, + assignee_ids: [assignee.id], label_ids: labels.map(&:id), milestone_id: milestone.id, due_date: Date.tomorrow } @@ -29,7 +29,7 @@ describe Issues::CreateService, services: true do it 'creates the issue with the given params' do expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') - expect(issue.assignee).to eq assignee + expect(issue.assignees).to eq [assignee] expect(issue.labels).to match_array labels expect(issue.milestone).to eq milestone expect(issue.due_date).to eq Date.tomorrow @@ -37,6 +37,7 @@ describe Issues::CreateService, services: true do context 'when current user cannot admin issues in the project' do let(:guest) { create(:user) } + before do project.team << [guest, :guest] end @@ -47,7 +48,7 @@ describe Issues::CreateService, services: true do expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') expect(issue.description).to eq('please fix') - expect(issue.assignee).to be_nil + expect(issue.assignees).to be_empty expect(issue.labels).to be_empty expect(issue.milestone).to be_nil expect(issue.due_date).to be_nil @@ -136,10 +137,83 @@ describe Issues::CreateService, services: true do end end - it_behaves_like 'issuable create service' + context 'issue create service' do + context 'assignees' do + before { project.team << [user, :master] } + + it 'removes assignee when user id is invalid' do + opts = { title: 'Title', description: 'Description', assignee_ids: [-1] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + + it 'removes assignee when user id is 0' do + opts = { title: 'Title', description: 'Description', assignee_ids: [0] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + + it 'saves assignee when user id is valid' do + project.team << [assignee, :master] + opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to eq([assignee]) + end + + context "when issuable feature is private" do + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) + end + + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + project.update(visibility_level: level) + opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + end + end + end + end it_behaves_like 'new issuable record that supports slash commands' + context 'Slash commands' do + context 'with assignee and milestone in params and command' do + let(:opts) do + { + assignee_ids: [create(:user).id], + milestone_id: 1, + title: 'Title', + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :master] + end + + it 'assigns and sets milestone to issuable from command' do + expect(issue).to be_persisted + expect(issue.assignees).to eq([assignee]) + expect(issue.milestone).to eq(milestone) + end + end + end + context 'resolving discussions' do let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 5b324f3c706..1954d8739f6 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -14,7 +14,7 @@ describe Issues::UpdateService, services: true do let(:issue) do create(:issue, title: 'Old title', description: "for #{user2.to_reference}", - assignee_id: user3.id, + assignee_ids: [user3.id], project: project) end @@ -40,7 +40,7 @@ describe Issues::UpdateService, services: true do { title: 'New title', description: 'Also please fix', - assignee_id: user2.id, + assignee_ids: [user2.id], state_event: 'close', label_ids: [label.id], due_date: Date.tomorrow @@ -53,15 +53,15 @@ describe Issues::UpdateService, services: true do expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignee).to eq user2 + expect(issue.assignees).to match_array([user2]) expect(issue).to be_closed expect(issue.labels).to match_array [label] expect(issue.due_date).to eq Date.tomorrow end it 'sorts issues as specified by parameters' do - issue1 = create(:issue, project: project, assignee_id: user3.id) - issue2 = create(:issue, project: project, assignee_id: user3.id) + issue1 = create(:issue, project: project, assignees: [user3]) + issue2 = create(:issue, project: project, assignees: [user3]) [issue, issue1, issue2].each do |issue| issue.move_to_end @@ -87,7 +87,7 @@ describe Issues::UpdateService, services: true do expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignee).to eq user3 + expect(issue.assignees).to match_array [user3] expect(issue.labels).to be_empty expect(issue.milestone).to be_nil expect(issue.due_date).to be_nil @@ -132,12 +132,23 @@ describe Issues::UpdateService, services: true do end end + context 'when description changed' do + it 'creates system note about description change' do + update_issue(description: 'Changed description') + + note = find_note('changed the description') + + expect(note).not_to be_nil + expect(note.note).to eq('changed the description') + end + end + context 'when issue turns confidential' do let(:opts) do { title: 'New title', description: 'Also please fix', - assignee_id: user2.id, + assignee_ids: [user2], state_event: 'close', label_ids: [label.id], confidential: true @@ -163,12 +174,12 @@ describe Issues::UpdateService, services: true do it 'does not update assignee_id with unauthorized users' do project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) update_issue(confidential: true) - non_member = create(:user) - original_assignee = issue.assignee + non_member = create(:user) + original_assignees = issue.assignees - update_issue(assignee_id: non_member.id) + update_issue(assignee_ids: [non_member.id]) - expect(issue.reload.assignee_id).to eq(original_assignee.id) + expect(issue.reload.assignees).to eq(original_assignees) end end @@ -205,7 +216,7 @@ describe Issues::UpdateService, services: true do context 'when is reassigned' do before do - update_issue(assignee: user2) + update_issue(assignees: [user2]) end it 'marks previous assignee todos as done' do @@ -408,6 +419,41 @@ describe Issues::UpdateService, services: true do end end + context 'updating asssignee_id' do + it 'does not update assignee when assignee_id is invalid' do + update_issue(assignee_ids: [-1]) + + expect(issue.reload.assignees).to eq([user3]) + end + + it 'unassigns assignee when user id is 0' do + update_issue(assignee_ids: [0]) + + expect(issue.reload.assignees).to be_empty + end + + it 'does not update assignee_id when user cannot read issue' do + update_issue(assignee_ids: [create(:user).id]) + + expect(issue.reload.assignees).to eq([user3]) + end + + context "when issuable feature is private" do + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + assignee = create(:user) + project.update(visibility_level: level) + feature_visibility_attr = :"#{issue.model_name.plural}_access_level" + project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) + + expect{ update_issue(assignee_ids: [assignee.id]) }.not_to change{ issue.assignees } + end + end + end + end + context 'updating mentions' do let(:mentionable) { issue } include_examples 'updating mentions', Issues::UpdateService diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb index 3b35a3b8e3a..ab440d18e9f 100644 --- a/spec/services/members/authorized_destroy_service_spec.rb +++ b/spec/services/members/authorized_destroy_service_spec.rb @@ -14,8 +14,8 @@ describe Members::AuthorizedDestroyService, services: true do it "unassigns issues and merge requests" do group.add_developer(member_user) - issue = create :issue, project: group_project, assignee: member_user - create :issue, assignee: member_user + issue = create :issue, project: group_project, assignees: [member_user] + create :issue, assignees: [member_user] merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user create :merge_request, target_project: project, source_project: project, assignee: member_user @@ -33,7 +33,7 @@ describe Members::AuthorizedDestroyService, services: true do it "unassigns issues and merge requests" do project.team << [member_user, :developer] - create :issue, project: project, assignee: member_user + create :issue, project: project, assignees: [member_user] create :merge_request, target_project: project, source_project: project, assignee: member_user member = project.members.find_by(user_id: member_user.id) diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb index fe75757dd29..d3556020d4d 100644 --- a/spec/services/merge_requests/assign_issues_service_spec.rb +++ b/spec/services/merge_requests/assign_issues_service_spec.rb @@ -15,14 +15,14 @@ describe MergeRequests::AssignIssuesService, services: true do expect(service.assignable_issues.map(&:id)).to include(issue.id) end - it 'ignores issues already assigned to any user' do - issue.update!(assignee: create(:user)) + it 'ignores issues the user cannot update assignee on' do + project.team.truncate expect(service.assignable_issues).to be_empty end - it 'ignores issues the user cannot update assignee on' do - project.team.truncate + it 'ignores issues already assigned to any user' do + issue.assignees = [create(:user)] expect(service.assignable_issues).to be_empty end @@ -44,7 +44,7 @@ describe MergeRequests::AssignIssuesService, services: true do end it 'assigns these to the merge request owner' do - expect { service.execute }.to change { issue.reload.assignee }.to(user) + expect { service.execute }.to change { issue.assignees.first }.to(user) end it 'ignores external issues' do diff --git a/spec/services/merge_requests/create_from_issue_service_spec.rb b/spec/services/merge_requests/create_from_issue_service_spec.rb new file mode 100644 index 00000000000..1588d30c394 --- /dev/null +++ b/spec/services/merge_requests/create_from_issue_service_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe MergeRequests::CreateFromIssueService, services: true do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + let(:issue) { create(:issue, project: project) } + + subject(:service) { described_class.new(project, user, issue_iid: issue.iid) } + + before do + project.add_developer(user) + end + + describe '#execute' do + it 'returns an error with invalid issue iid' do + result = described_class.new(project, user, issue_iid: -1).execute + + expect(result[:status]).to eq :error + expect(result[:message]).to eq 'Invalid issue iid' + end + + it 'delegates issue search to IssuesFinder' do + expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original + + described_class.new(project, user, issue_iid: -1).execute + end + + it 'delegates the branch creation to CreateBranchService' do + expect_any_instance_of(CreateBranchService).to receive(:execute).once.and_call_original + + service.execute + end + + it 'creates a branch based on issue title' do + service.execute + + expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy + end + + it 'creates a system note' do + expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name) + + service.execute + end + + it 'creates a merge request' do + expect { service.execute }.to change(project.merge_requests, :count).by(1) + end + + it 'sets the merge request title to: "WIP: Resolves "$issue-title"' do + result = service.execute + + expect(result[:merge_request].title).to eq("WIP: Resolve \"#{issue.title}\"") + end + + it 'sets the merge request author to current user' do + result = service.execute + + expect(result[:merge_request].author).to eq user + end + + it 'sets the merge request source branch to the new issue branch' do + result = service.execute + + expect(result[:merge_request].source_branch).to eq issue.to_branch_name + end + + it 'sets the merge request target branch to the project default branch' do + result = service.execute + + expect(result[:merge_request].target_branch).to eq project.default_branch + end + end +end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 0e16c7cc94b..ace82380cc9 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -84,7 +84,87 @@ describe MergeRequests::CreateService, services: true do end end - it_behaves_like 'issuable create service' + context 'Slash commands' do + context 'with assignee and milestone in params and command' do + let(:merge_request) { described_class.new(project, user, opts).execute } + let(:milestone) { create(:milestone, project: project) } + + let(:opts) do + { + assignee_id: create(:user).id, + milestone_id: 1, + title: 'Title', + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}"), + source_branch: 'feature', + target_branch: 'master' + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :master] + end + + it 'assigns and sets milestone to issuable from command' do + expect(merge_request).to be_persisted + expect(merge_request.assignee).to eq(assignee) + expect(merge_request.milestone).to eq(milestone) + end + end + end + + context 'merge request create service' do + context 'asssignee_id' do + let(:assignee) { create(:user) } + + before { project.team << [user, :master] } + + it 'removes assignee_id when user id is invalid' do + opts = { title: 'Title', description: 'Description', assignee_id: -1 } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + + it 'removes assignee_id when user id is 0' do + opts = { title: 'Title', description: 'Description', assignee_id: 0 } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + + it 'saves assignee when user id is valid' do + project.team << [assignee, :master] + opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee).to eq(assignee) + end + + context "when issuable feature is private" do + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) + end + + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + project.update(visibility_level: level) + opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + end + end + end + end context 'while saving references to issues that the created merge request closes' do let(:first_issue) { create(:issue, project: project) } diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb index 290e00ea1ba..4a7d8ab4c6c 100644 --- a/spec/services/merge_requests/get_urls_service_spec.rb +++ b/spec/services/merge_requests/get_urls_service_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" describe MergeRequests::GetUrlsService do let(:project) { create(:project, :public, :repository) } - let(:service) { MergeRequests::GetUrlsService.new(project) } + let(:service) { described_class.new(project) } let(:source_branch) { "my_branch" } let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" } let(:show_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" } @@ -89,7 +89,7 @@ describe MergeRequests::GetUrlsService do let!(:merge_request) { create(:merge_request, source_project: forked_project, target_project: project, source_branch: source_branch) } let(:changes) { existing_branch_changes } # Source project is now the forked one - let(:service) { MergeRequests::GetUrlsService.new(forked_project) } + let(:service) { described_class.new(forked_project) } before do allow(forked_project).to receive(:empty_repo?).and_return(false) diff --git a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb index 35804d41b46..935f4710851 100644 --- a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb +++ b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe MergeRequests::MergeRequestDiffCacheService do - let(:subject) { MergeRequests::MergeRequestDiffCacheService.new } + let(:subject) { described_class.new } describe '#execute' do it 'retrieves the diff files to cache the highlighted result' do diff --git a/spec/services/merge_requests/resolve_service_spec.rb b/spec/services/merge_requests/resolve_service_spec.rb index eaf7785e549..3afd6b92900 100644 --- a/spec/services/merge_requests/resolve_service_spec.rb +++ b/spec/services/merge_requests/resolve_service_spec.rb @@ -50,7 +50,7 @@ describe MergeRequests::ResolveService do context 'when the source and target project are the same' do before do - MergeRequests::ResolveService.new(project, user, params).execute(merge_request) + described_class.new(project, user, params).execute(merge_request) end it 'creates a commit with the message' do @@ -75,7 +75,7 @@ describe MergeRequests::ResolveService do end before do - MergeRequests::ResolveService.new(fork_project, user, params).execute(merge_request_from_fork) + described_class.new(fork_project, user, params).execute(merge_request_from_fork) end it 'creates a commit with the message' do @@ -115,7 +115,7 @@ describe MergeRequests::ResolveService do end before do - MergeRequests::ResolveService.new(project, user, params).execute(merge_request) + described_class.new(project, user, params).execute(merge_request) end it 'creates a commit with the message' do @@ -154,7 +154,7 @@ describe MergeRequests::ResolveService do } end - let(:service) { MergeRequests::ResolveService.new(project, user, invalid_params) } + let(:service) { described_class.new(project, user, invalid_params) } it 'raises a MissingResolution error' do expect { service.execute(merge_request) }. @@ -180,7 +180,7 @@ describe MergeRequests::ResolveService do } end - let(:service) { MergeRequests::ResolveService.new(project, user, invalid_params) } + let(:service) { described_class.new(project, user, invalid_params) } it 'raises a MissingResolution error' do expect { service.execute(merge_request) }. @@ -202,7 +202,7 @@ describe MergeRequests::ResolveService do } end - let(:service) { MergeRequests::ResolveService.new(project, user, invalid_params) } + let(:service) { described_class.new(project, user, invalid_params) } it 'raises a MissingFiles error' do expect { service.execute(merge_request) }. diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index f2ca1e6fcbd..31487c0f794 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -102,6 +102,13 @@ describe MergeRequests::UpdateService, services: true do expect(note.note).to eq 'changed title from **{-Old-} title** to **{+New+} title**' end + it 'creates system note about description change' do + note = find_note('changed the description') + + expect(note).not_to be_nil + expect(note.note).to eq('changed the description') + end + it 'creates system note about branch change' do note = find_note('changed target') @@ -423,6 +430,54 @@ describe MergeRequests::UpdateService, services: true do end end + context 'updating asssignee_id' do + it 'does not update assignee when assignee_id is invalid' do + merge_request.update(assignee_id: user.id) + + update_merge_request(assignee_id: -1) + + expect(merge_request.reload.assignee).to eq(user) + end + + it 'unassigns assignee when user id is 0' do + merge_request.update(assignee_id: user.id) + + update_merge_request(assignee_id: 0) + + expect(merge_request.assignee_id).to be_nil + end + + it 'saves assignee when user id is valid' do + update_merge_request(assignee_id: user.id) + + expect(merge_request.assignee_id).to eq(user.id) + end + + it 'does not update assignee_id when user cannot read issue' do + non_member = create(:user) + original_assignee = merge_request.assignee + + update_merge_request(assignee_id: non_member.id) + + expect(merge_request.assignee_id).to eq(original_assignee.id) + end + + context "when issuable feature is private" do + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + assignee = create(:user) + project.update(visibility_level: level) + feature_visibility_attr = :"#{merge_request.model_name.plural}_access_level" + project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) + + expect{ update_merge_request(assignee_id: assignee) }.not_to change{ merge_request.assignee } + end + end + end + end + include_examples 'issuable update service' do let(:open_issuable) { merge_request } let(:closed_issuable) { create(:closed_merge_request, source_project: project) } diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb index f9dd5541b10..133175769ca 100644 --- a/spec/services/notes/build_service_spec.rb +++ b/spec/services/notes/build_service_spec.rb @@ -29,10 +29,82 @@ describe Notes::BuildService, services: true do expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') end end + + context 'personal snippet note' do + def reply(note, user = nil) + user ||= create(:user) + + described_class.new(nil, + user, + note: 'Test', + in_reply_to_discussion_id: note.discussion_id).execute + end + + let(:snippet_author) { create(:user) } + + context 'when a snippet is public' do + it 'creates a reply note' do + snippet = create(:personal_snippet, :public) + note = create(:discussion_note_on_personal_snippet, noteable: snippet) + + new_note = reply(note) + + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'when a snippet is private' do + let(:snippet) { create(:personal_snippet, :private, author: snippet_author) } + let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) } + + it 'creates a reply note when the author replies' do + new_note = reply(note, snippet_author) + + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + + it 'sets an error when another user replies' do + new_note = reply(note) + + expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') + end + end + + context 'when a snippet is internal' do + let(:snippet) { create(:personal_snippet, :internal, author: snippet_author) } + let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) } + + it 'creates a reply note when the author replies' do + new_note = reply(note, snippet_author) + + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + + it 'creates a reply note when a regular user replies' do + new_note = reply(note) + + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + + it 'sets an error when an external user replies' do + new_note = reply(note, create(:user, :external)) + + expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') + end + end + end end it 'builds a note without saving it' do - new_note = described_class.new(project, author, noteable_type: note.noteable_type, noteable_id: note.noteable_id, note: 'Test').execute + new_note = described_class.new(project, + author, + noteable_type: note.noteable_type, + noteable_id: note.noteable_id, + note: 'Test').execute expect(new_note).to be_valid expect(new_note).not_to be_persisted end diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index 1a64c8bbf00..c9954dc3603 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -66,7 +66,7 @@ describe Notes::SlashCommandsService, services: true do expect(content).to eq '' expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) - expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.assignees).to eq([assignee]) expect(note.noteable.milestone).to eq(milestone) end end @@ -113,7 +113,7 @@ describe Notes::SlashCommandsService, services: true do expect(content).to eq "HELLO\nWORLD" expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) - expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.assignees).to eq([assignee]) expect(note.noteable.milestone).to eq(milestone) end end @@ -220,4 +220,31 @@ describe Notes::SlashCommandsService, services: true do let(:note) { build(:note_on_commit, project: project) } end end + + context 'CE restriction for issue assignees' do + describe '/assign' do + let(:project) { create(:empty_project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + let(:master) { create(:user) } + let(:service) { described_class.new(project, master) } + let(:note) { create(:note_on_issue, note: note_text, project: project) } + + let(:note_text) do + %(/assign @#{assignee.username} @#{master.username}\n") + end + + before do + project.team << [master, :master] + project.team << [assignee, :master] + end + + it 'adds only one assignee from the list' do + _, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(note.noteable.assignees.count).to eq(1) + end + end + end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 989fd90cda9..74f96b97909 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -4,6 +4,7 @@ describe NotificationService, services: true do include EmailHelpers let(:notification) { NotificationService.new } + let(:assignee) { create(:user) } around(:each) do |example| perform_enqueued_jobs do @@ -52,7 +53,11 @@ describe NotificationService, services: true do shared_examples 'participating by assignee notification' do it 'emails the participant' do - issuable.update_attribute(:assignee, participant) + if issuable.is_a?(Issue) + issuable.assignees << participant + else + issuable.update_attribute(:assignee, participant) + end notification_trigger @@ -103,14 +108,14 @@ describe NotificationService, services: true do describe 'Notes' do context 'issue note' do let(:project) { create(:empty_project, :private) } - let(:issue) { create(:issue, project: project, assignee: create(:user)) } - let(:mentioned_issue) { create(:issue, assignee: issue.assignee) } + let(:issue) { create(:issue, project: project, assignees: [assignee]) } + let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') } before do build_team(note.project) project.add_master(issue.author) - project.add_master(issue.assignee) + project.add_master(assignee) project.add_master(note.author) create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@subscribed_participant cc this guy') update_custom_notification(:new_note, @u_guest_custom, resource: project) @@ -130,7 +135,7 @@ describe NotificationService, services: true do should_email(@u_watcher) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_email(@u_custom_global) should_email(@u_mentioned) should_email(@subscriber) @@ -196,7 +201,7 @@ describe NotificationService, services: true do notification.new_note(note) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_email(@u_mentioned) should_email(@u_custom_global) should_not_email(@u_guest_custom) @@ -218,7 +223,7 @@ describe NotificationService, services: true do let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note) { create(:note_on_issue, noteable: confidential_issue, project: project, note: "#{author.to_reference} #{assignee.to_reference} #{non_member.to_reference} #{member.to_reference} #{admin.to_reference}") } let(:guest_watcher) { create_user_with_notification(:watch, "guest-watcher-confidential") } @@ -244,8 +249,8 @@ describe NotificationService, services: true do context 'issue note mention' do let(:project) { create(:empty_project, :public) } - let(:issue) { create(:issue, project: project, assignee: create(:user)) } - let(:mentioned_issue) { create(:issue, assignee: issue.assignee) } + let(:issue) { create(:issue, project: project, assignees: [assignee]) } + let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@all mentioned') } before do @@ -269,7 +274,7 @@ describe NotificationService, services: true do should_email(@u_guest_watcher) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_not_email(note.author) should_email(@u_mentioned) should_not_email(@u_disabled) @@ -449,7 +454,7 @@ describe NotificationService, services: true do let(:group) { create(:group) } let(:project) { create(:empty_project, :public, namespace: group) } let(:another_project) { create(:empty_project, :public, namespace: group) } - let(:issue) { create :issue, project: project, assignee: create(:user), description: 'cc @participant' } + let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' } before do build_team(issue.project) @@ -465,7 +470,7 @@ describe NotificationService, services: true do it do notification.new_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(assignee) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -480,10 +485,10 @@ describe NotificationService, services: true do end it do - create_global_setting_for(issue.assignee, :mention) + create_global_setting_for(issue.assignees.first, :mention) notification.new_issue(issue, @u_disabled) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) end it "emails the author if they've opted into notifications about their activity" do @@ -528,7 +533,7 @@ describe NotificationService, services: true do let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) } it "emails subscribers of the issue's labels that can read the issue" do project.add_developer(member) @@ -572,9 +577,9 @@ describe NotificationService, services: true do end it 'emails new assignee' do - notification.reassigned_issue(issue, @u_disabled) + notification.reassigned_issue(issue, @u_disabled, [assignee]) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -588,9 +593,8 @@ describe NotificationService, services: true do end it 'emails previous assignee even if he has the "on mention" notif level' do - issue.update_attribute(:assignee, @u_mentioned) - issue.update_attributes(assignee: @u_watcher) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_watcher]) should_email(@u_mentioned) should_email(@u_watcher) @@ -606,11 +610,11 @@ describe NotificationService, services: true do end it 'emails new assignee even if he has the "on mention" notif level' do - issue.update_attributes(assignee: @u_mentioned) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned - should_email(issue.assignee) + expect(issue.assignees.first).to be @u_mentioned + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -624,11 +628,11 @@ describe NotificationService, services: true do end it 'emails new assignee' do - issue.update_attribute(:assignee, @u_mentioned) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned - should_email(issue.assignee) + expect(issue.assignees.first).to be @u_mentioned + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -642,17 +646,17 @@ describe NotificationService, services: true do end it 'does not email new assignee if they are the current user' do - issue.update_attribute(:assignee, @u_mentioned) - notification.reassigned_issue(issue, @u_mentioned) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_mentioned, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned + expect(issue.assignees.first).to be @u_mentioned should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) should_email(@u_participant_mentioned) should_email(@subscriber) should_email(@u_custom_global) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) @@ -662,7 +666,7 @@ describe NotificationService, services: true do it_behaves_like 'participating notifications' do let(:participant) { create(:user, username: 'user-participant') } let(:issuable) { issue } - let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled) } + let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } end end @@ -705,7 +709,7 @@ describe NotificationService, services: true do it "doesn't send email to anyone but subscribers of the given labels" do notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) should_not_email(issue.author) should_not_email(@u_watcher) should_not_email(@u_guest_watcher) @@ -729,7 +733,7 @@ describe NotificationService, services: true do let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) } let!(:label_1) { create(:label, project: project, issues: [confidential_issue]) } let!(:label_2) { create(:label, project: project) } @@ -767,7 +771,7 @@ describe NotificationService, services: true do it 'sends email to issue assignee and issue author' do notification.close_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) @@ -798,7 +802,7 @@ describe NotificationService, services: true do it 'sends email to issue notification recipients' do notification.reopen_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) @@ -826,7 +830,7 @@ describe NotificationService, services: true do it 'sends email to issue notification recipients' do notification.issue_moved(issue, new_issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb new file mode 100644 index 00000000000..b2fb5c91313 --- /dev/null +++ b/spec/services/preview_markdown_service_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe PreviewMarkdownService do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + + before do + project.add_developer(user) + end + + describe 'user references' do + let(:params) { { text: "Take a look #{user.to_reference}" } } + let(:service) { described_class.new(project, user, params) } + + it 'returns users referenced in text' do + result = service.execute + + expect(result[:users]).to eq [user.username] + end + end + + context 'new note with slash commands' do + let(:issue) { create(:issue, project: project) } + let(:params) do + { + text: "Please do it\n/assign #{user.to_reference}", + slash_commands_target_type: 'Issue', + slash_commands_target_id: issue.id + } + end + let(:service) { described_class.new(project, user, params) } + + it 'removes slash commands from text' do + result = service.execute + + expect(result[:text]).to eq 'Please do it' + end + + it 'explains slash commands effect' do + result = service.execute + + expect(result[:commands]).to eq "Assigns #{user.to_reference}." + end + end + + context 'merge request description' do + let(:params) do + { + text: "My work\n/estimate 2y", + slash_commands_target_type: 'MergeRequest' + } + end + let(:service) { described_class.new(project, user, params) } + + it 'removes slash commands from text' do + result = service.execute + + expect(result[:text]).to eq 'My work' + end + + it 'explains slash commands effect' do + result = service.execute + + expect(result[:commands]).to eq 'Sets time estimate to 2y.' + end + end +end diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb index 7916c2d957c..c198c3eedfc 100644 --- a/spec/services/projects/autocomplete_service_spec.rb +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -11,7 +11,7 @@ describe Projects::AutocompleteService, services: true do let(:project) { create(:empty_project, :public) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } it 'does not list project confidential issues for guests' do autocomplete = described_class.new(project, nil) diff --git a/spec/services/projects/enable_deploy_key_service_spec.rb b/spec/services/projects/enable_deploy_key_service_spec.rb index a37510cf159..78626fbad4b 100644 --- a/spec/services/projects/enable_deploy_key_service_spec.rb +++ b/spec/services/projects/enable_deploy_key_service_spec.rb @@ -21,6 +21,16 @@ describe Projects::EnableDeployKeyService, services: true do end end + context 'add the same key twice' do + before do + project.deploy_keys << deploy_key + end + + it 'returns existing key' do + expect(service.execute).to eq(deploy_key) + end + end + def service Projects::EnableDeployKeyService.new(project, user, params) end diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index eaf63457b32..fff12beed71 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Projects::HousekeepingService do - subject { Projects::HousekeepingService.new(project) } + subject { described_class.new(project) } let(:project) { create(:project, :repository) } before do diff --git a/spec/services/projects/propagate_service_template_spec.rb b/spec/services/projects/propagate_service_template_spec.rb new file mode 100644 index 00000000000..90eff3bbc1e --- /dev/null +++ b/spec/services/projects/propagate_service_template_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' + +describe Projects::PropagateServiceTemplate, services: true do + describe '.propagate' do + let!(:service_template) do + PushoverService.create( + template: true, + active: true, + properties: { + device: 'MyDevice', + sound: 'mic', + priority: 4, + user_key: 'asdf', + api_key: '123456789' + }) + end + + let!(:project) { create(:empty_project) } + + it 'creates services for projects' do + expect(project.pushover_service).to be_nil + + described_class.propagate(service_template) + + expect(project.reload.pushover_service).to be_present + end + + it 'creates services for a project that has another service' do + BambooService.create( + template: true, + active: true, + project: project, + properties: { + bamboo_url: 'http://gitlab.com', + username: 'mic', + password: "password", + build_key: 'build' + } + ) + + expect(project.pushover_service).to be_nil + + described_class.propagate(service_template) + + expect(project.reload.pushover_service).to be_present + end + + it 'does not create the service if it exists already' do + other_service = BambooService.create( + template: true, + active: true, + properties: { + bamboo_url: 'http://gitlab.com', + username: 'mic', + password: "password", + build_key: 'build' + } + ) + + Service.build_from_template(project.id, service_template).save! + Service.build_from_template(project.id, other_service).save! + + expect { described_class.propagate(service_template) }. + not_to change { Service.count } + end + + it 'creates the service containing the template attributes' do + described_class.propagate(service_template) + + expect(project.pushover_service.properties).to eq(service_template.properties) + end + + describe 'bulk update' do + it 'creates services for all projects' do + project_total = 5 + stub_const 'Projects::PropagateServiceTemplate::BATCH_SIZE', 3 + + project_total.times { create(:empty_project) } + + expect { described_class.propagate(service_template) }. + to change { Service.count }.by(project_total + 1) + end + end + + describe 'external tracker' do + it 'updates the project external tracker' do + service_template.update!(category: 'issue_tracker', default: false) + + expect { described_class.propagate(service_template) }. + to change { project.reload.has_external_issue_tracker }.to(true) + end + end + + describe 'external wiki' do + it 'updates the project external tracker' do + service_template.update!(type: 'ExternalWikiService') + + expect { described_class.propagate(service_template) }. + to change { project.reload.has_external_wiki }.to(true) + end + end + end +end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 29e65fe7ce6..e5e400ee281 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe SlashCommands::InterpretService, services: true do let(:project) { create(:empty_project, :public) } let(:developer) { create(:user) } + let(:developer2) { create(:user) } let(:issue) { create(:issue, project: project) } let(:milestone) { create(:milestone, project: project, title: '9.10') } let(:inprogress) { create(:label, project: project, title: 'In Progress') } @@ -42,23 +43,6 @@ describe SlashCommands::InterpretService, services: true do end end - shared_examples 'assign command' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: developer.id) - end - end - - shared_examples 'unassign command' do - it 'populates assignee_id: nil if content contains /unassign' do - issuable.update!(assignee_id: developer.id) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: nil) - end - end - shared_examples 'milestone command' do it 'fetches milestone and populates milestone_id if content contains /milestone' do milestone # populate the milestone @@ -371,14 +355,46 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { issue } end - it_behaves_like 'assign command' do + context 'assign command' do let(:content) { "/assign @#{developer.username}" } - let(:issuable) { issue } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: [developer.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end end - it_behaves_like 'assign command' do - let(:content) { "/assign @#{developer.username}" } - let(:issuable) { merge_request } + context 'assign command with multiple assignees' do + let(:content) { "/assign @#{developer.username} @#{developer2.username}" } + + before{ project.team << [developer2, :developer] } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end end it_behaves_like 'empty command' do @@ -391,14 +407,26 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { issue } end - it_behaves_like 'unassign command' do + context 'unassign command' do let(:content) { '/unassign' } - let(:issuable) { issue } - end - it_behaves_like 'unassign command' do - let(:content) { '/unassign' } - let(:issuable) { merge_request } + context 'Issue' do + it 'populates assignee_ids: [] if content contains /unassign' do + issue.update(assignee_ids: [developer.id]) + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: []) + end + end + + context 'Merge Request' do + it 'populates assignee_id: nil if content contains /unassign' do + merge_request.update(assignee_id: developer.id) + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: nil) + end + end end it_behaves_like 'milestone command' do @@ -798,4 +826,211 @@ describe SlashCommands::InterpretService, services: true do end end end + + describe '#explain' do + let(:service) { described_class.new(project, developer) } + let(:merge_request) { create(:merge_request, source_project: project) } + + describe 'close command' do + let(:content) { '/close' } + + it 'includes issuable name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Closes this issue.']) + end + end + + describe 'reopen command' do + let(:content) { '/reopen' } + let(:merge_request) { create(:merge_request, :closed, source_project: project) } + + it 'includes issuable name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Reopens this merge request.']) + end + end + + describe 'title command' do + let(:content) { '/title This is new title' } + + it 'includes new title' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Changes the title to "This is new title".']) + end + end + + describe 'assign command' do + let(:content) { "/assign @#{developer.username} do it!" } + + it 'includes only the user reference' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(["Assigns @#{developer.username}."]) + end + end + + describe 'unassign command' do + let(:content) { '/unassign' } + let(:issue) { create(:issue, project: project, assignees: [developer]) } + + it 'includes current assignee reference' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Removes assignee @#{developer.username}."]) + end + end + + describe 'milestone command' do + let(:content) { '/milestone %wrong-milestone' } + let!(:milestone) { create(:milestone, project: project, title: '9.10') } + + it 'is empty when milestone reference is wrong' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq([]) + end + end + + describe 'remove milestone command' do + let(:content) { '/remove_milestone' } + let(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) } + + it 'includes current milestone name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Removes %"9.10" milestone.']) + end + end + + describe 'label command' do + let(:content) { '/label ~missing' } + let!(:label) { create(:label, project: project) } + + it 'is empty when there are no correct labels' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq([]) + end + end + + describe 'unlabel command' do + let(:content) { '/unlabel' } + + it 'says all labels if no parameter provided' do + merge_request.update!(label_ids: [bug.id]) + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Removes all labels.']) + end + end + + describe 'relabel command' do + let(:content) { '/relabel Bug' } + let!(:bug) { create(:label, project: project, title: 'Bug') } + let(:feature) { create(:label, project: project, title: 'Feature') } + + it 'includes label name' do + issue.update!(label_ids: [feature.id]) + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Replaces all labels with ~#{bug.id} label."]) + end + end + + describe 'subscribe command' do + let(:content) { '/subscribe' } + + it 'includes issuable name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Subscribes to this issue.']) + end + end + + describe 'unsubscribe command' do + let(:content) { '/unsubscribe' } + + it 'includes issuable name' do + merge_request.subscribe(developer, project) + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Unsubscribes from this merge request.']) + end + end + + describe 'due command' do + let(:content) { '/due April 1st 2016' } + + it 'includes the date' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Sets the due date to Apr 1, 2016.']) + end + end + + describe 'wip command' do + let(:content) { '/wip' } + + it 'includes the new status' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Marks this merge request as Work In Progress.']) + end + end + + describe 'award command' do + let(:content) { '/award :confetti_ball: ' } + + it 'includes the emoji' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Toggles :confetti_ball: emoji award.']) + end + end + + describe 'estimate command' do + let(:content) { '/estimate 79d' } + + it 'includes the formatted duration' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Sets time estimate to 3mo 3w 4d.']) + end + end + + describe 'spend command' do + let(:content) { '/spend -120m' } + + it 'includes the formatted duration and proper verb' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Substracts 2h spent time.']) + end + end + + describe 'target branch command' do + let(:content) { '/target_branch my-feature ' } + + it 'includes the branch name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Sets target branch to my-feature.']) + end + end + + describe 'board move command' do + let(:content) { '/board_move ~bug' } + let!(:bug) { create(:label, project: project, title: 'bug') } + let!(:board) { create(:board, project: project) } + + it 'includes the label name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Moves issue to ~#{bug.id} column in the board."]) + end + end + end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 75d7caf2508..516566eddef 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -6,6 +6,7 @@ describe SystemNoteService, services: true do let(:project) { create(:empty_project) } let(:author) { create(:user) } let(:noteable) { create(:issue, project: project) } + let(:issue) { noteable } shared_examples_for 'a system note' do let(:expected_noteable) { noteable } @@ -155,6 +156,52 @@ describe SystemNoteService, services: true do end end + describe '.change_issue_assignees' do + subject { described_class.change_issue_assignees(noteable, project, author, [assignee]) } + + let(:assignee) { create(:user) } + let(:assignee1) { create(:user) } + let(:assignee2) { create(:user) } + let(:assignee3) { create(:user) } + + it_behaves_like 'a system note' do + let(:action) { 'assignee' } + end + + def build_note(old_assignees, new_assignees) + issue.assignees = new_assignees + described_class.change_issue_assignees(issue, project, author, old_assignees).note + end + + it 'builds a correct phrase when an assignee is added to a non-assigned issue' do + expect(build_note([], [assignee1])).to eq "assigned to @#{assignee1.username}" + end + + it 'builds a correct phrase when assignee removed' do + expect(build_note([assignee1], [])).to eq 'removed all assignees' + end + + it 'builds a correct phrase when assignees changed' do + expect(build_note([assignee1], [assignee2])).to eq \ + "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}" + end + + it 'builds a correct phrase when three assignees removed and one added' do + expect(build_note([assignee, assignee1, assignee2], [assignee3])).to eq \ + "assigned to @#{assignee3.username} and unassigned @#{assignee.username}, @#{assignee1.username}, and @#{assignee2.username}" + end + + it 'builds a correct phrase when one assignee changed from a set' do + expect(build_note([assignee, assignee1], [assignee, assignee2])).to eq \ + "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}" + end + + it 'builds a correct phrase when one assignee removed from a set' do + expect(build_note([assignee, assignee1, assignee2], [assignee, assignee1])).to eq \ + "unassigned @#{assignee2.username}" + end + end + describe '.change_label' do subject { described_class.change_label(noteable, project, author, added, removed) } @@ -292,6 +339,20 @@ describe SystemNoteService, services: true do end end + describe '.change_description' do + subject { described_class.change_description(noteable, project, author) } + + context 'when noteable responds to `description`' do + it_behaves_like 'a system note' do + let(:action) { 'description' } + end + + it 'sets the note text' do + expect(subject.note).to eq('changed the description') + end + end + end + describe '.change_issue_confidentiality' do subject { described_class.change_issue_confidentiality(noteable, project, author) } diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 89b3b6aad10..175a42a32d9 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -25,11 +25,11 @@ describe TodoService, services: true do end describe 'Issues' do - let(:issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") } - let(:addressed_issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") } - let(:unassigned_issue) { create(:issue, project: project, assignee: nil) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: mentions) } - let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: directly_addressed) } + let(:issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") } + let(:addressed_issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") } + let(:unassigned_issue) { create(:issue, project: project, assignees: []) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: mentions) } + let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: directly_addressed) } describe '#new_issue' do it 'creates a todo if assigned' do @@ -43,7 +43,7 @@ describe TodoService, services: true do end it 'creates a todo if assignee is the current user' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees = [john_doe] service.new_issue(unassigned_issue, john_doe) should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED) @@ -258,20 +258,20 @@ describe TodoService, services: true do describe '#reassigned_issue' do it 'creates a pending todo for new assignee' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees << john_doe service.reassigned_issue(unassigned_issue, author) should_create_todo(user: john_doe, target: unassigned_issue, action: Todo::ASSIGNED) end it 'does not create a todo if unassigned' do - issue.update_attribute(:assignee, nil) + issue.assignees.destroy_all should_not_create_any_todo { service.reassigned_issue(issue, author) } end it 'creates a todo if new assignee is the current user' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees << john_doe service.reassigned_issue(unassigned_issue, john_doe) should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED) @@ -361,7 +361,7 @@ describe TodoService, services: true do describe '#new_note' do let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note) { create(:note, project: project, noteable: issue, author: john_doe, note: mentions) } let(:addressed_note) { create(:note, project: project, noteable: issue, author: john_doe, note: directly_addressed) } let(:note_on_commit) { create(:note_on_commit, project: project, author: john_doe, note: mentions) } @@ -854,7 +854,7 @@ describe TodoService, services: true do end it 'updates cached counts when a todo is created' do - issue = create(:issue, project: project, assignee: john_doe, author: author, description: mentions) + issue = create(:issue, project: project, assignees: [john_doe], author: author, description: mentions) expect(john_doe.todos_pending_count).to eq(0) expect(john_doe).to receive(:update_todos_count_cache).and_call_original @@ -866,8 +866,8 @@ describe TodoService, services: true do end describe '#mark_todos_as_done' do - let(:issue) { create(:issue, project: project, author: author, assignee: john_doe) } - let(:another_issue) { create(:issue, project: project, author: author, assignee: john_doe) } + let(:issue) { create(:issue, project: project, author: author, assignees: [john_doe]) } + let(:another_issue) { create(:issue, project: project, author: author, assignees: [john_doe]) } it 'marks a relation of todos as done' do create(:todo, :mentioned, user: john_doe, target: issue, project: project) diff --git a/spec/services/projects/upload_service_spec.rb b/spec/services/upload_service_spec.rb index d2cefa46bfa..95ba28dbecd 100644 --- a/spec/services/projects/upload_service_spec.rb +++ b/spec/services/upload_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Projects::UploadService, services: true do +describe UploadService, services: true do describe 'File service' do before do @user = create(:user) @@ -68,6 +68,6 @@ describe Projects::UploadService, services: true do end def upload_file(project, file) - Projects::UploadService.new(project, file).execute + described_class.new(project, file, FileUploader).execute end end diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index 4bc30018ebd..de37a61e388 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -47,7 +47,7 @@ describe Users::DestroyService, services: true do end context "for an issue the user was assigned to" do - let!(:issue) { create(:issue, project: project, assignee: user) } + let!(:issue) { create(:issue, project: project, assignees: [user]) } before do service.execute(user) @@ -60,7 +60,7 @@ describe Users::DestroyService, services: true do it 'migrates the issue so that it is "Unassigned"' do migrated_issue = Issue.find_by_id(issue.id) - expect(migrated_issue.assignee).to be_nil + expect(migrated_issue.assignees).to be_empty end end end diff --git a/spec/support/fake_migration_classes.rb b/spec/support/fake_migration_classes.rb new file mode 100644 index 00000000000..3de0460c3ca --- /dev/null +++ b/spec/support/fake_migration_classes.rb @@ -0,0 +1,3 @@ +class FakeRenameReservedPathMigrationV1 < ActiveRecord::Migration + include Gitlab::Database::RenameReservedPathsMigration::V1 +end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 5bbe36d9b7f..ad46b163cd6 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -25,7 +25,7 @@ shared_examples 'issuable record that supports slash commands in its description wait_for_ajax end - describe "new #{issuable_type}" do + describe "new #{issuable_type}", js: true do context 'with commands in the description' do it "creates the #{issuable_type} and interpret commands accordingly" do visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts) @@ -44,7 +44,7 @@ shared_examples 'issuable record that supports slash commands in its description end end - describe "note on #{issuable_type}" do + describe "note on #{issuable_type}", js: true do before do visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) end @@ -58,11 +58,12 @@ shared_examples 'issuable record that supports slash commands in its description expect(page).not_to have_content '/label ~bug' expect(page).not_to have_content '/milestone %"ASAP"' + wait_for_ajax issuable.reload note = issuable.notes.user.first expect(note.note).to eq "Awesome!" - expect(issuable.assignee).to eq assignee + expect(issuable.assignees).to eq [assignee] expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone end @@ -80,7 +81,7 @@ shared_examples 'issuable record that supports slash commands in its description issuable.reload expect(issuable.notes.user).to be_empty - expect(issuable.assignee).to eq assignee + expect(issuable.assignees).to eq [assignee] expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone end @@ -257,4 +258,19 @@ shared_examples 'issuable record that supports slash commands in its description end end end + + describe "preview of note on #{issuable_type}" do + it 'removes slash commands from note and explains them' do + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "Awesome!\n/assign @bob " + click_on 'Preview' + + expect(page).to have_content 'Awesome!' + expect(page).not_to have_content '/assign @bob' + expect(page).to have_content 'Assigns @bob.' + end + end + end end diff --git a/spec/support/helpers/fake_blob_helpers.rb b/spec/support/helpers/fake_blob_helpers.rb index b29af732ad3..bc9686ed9cf 100644 --- a/spec/support/helpers/fake_blob_helpers.rb +++ b/spec/support/helpers/fake_blob_helpers.rb @@ -1,6 +1,6 @@ module FakeBlobHelpers class FakeBlob - include Linguist::BlobHelper + include BlobLike attr_reader :path, :size, :data, :lfs_oid, :lfs_size @@ -19,10 +19,6 @@ module FakeBlobHelpers alias_method :name, :path - def mode - nil - end - def id 0 end @@ -31,17 +27,11 @@ module FakeBlobHelpers @binary end - def load_all_data!(repository) - # No-op + def external_storage + :lfs if @lfs_pointer end - def lfs_pointer? - @lfs_pointer - end - - def truncated? - false - end + alias_method :external_size, :lfs_size end def fake_blob(**kwargs) diff --git a/spec/support/import_export/export_file_helper.rb b/spec/support/import_export/export_file_helper.rb index 944ea30656f..57b6abe12b7 100644 --- a/spec/support/import_export/export_file_helper.rb +++ b/spec/support/import_export/export_file_helper.rb @@ -10,7 +10,7 @@ module ExportFileHelper create(:release, project: project) - issue = create(:issue, assignee: user, project: project) + issue = create(:issue, assignees: [user], project: project) snippet = create(:project_snippet, project: project) label = create(:label, project: project) milestone = create(:milestone, project: project) diff --git a/spec/support/import_export/import_export.yml b/spec/support/import_export/import_export.yml index 17136dee000..734d6838f4d 100644 --- a/spec/support/import_export/import_export.yml +++ b/spec/support/import_export/import_export.yml @@ -11,9 +11,6 @@ project_tree: - :user included_attributes: - project: - - :name - - :path merge_requests: - :id user: @@ -21,4 +18,7 @@ included_attributes: excluded_attributes: merge_requests: - - :iid
\ No newline at end of file + - :iid + project: + - :id + - :created_at
\ No newline at end of file diff --git a/spec/support/matchers/gitlab_git_matchers.rb b/spec/support/matchers/gitlab_git_matchers.rb new file mode 100644 index 00000000000..c840cd4bf2d --- /dev/null +++ b/spec/support/matchers/gitlab_git_matchers.rb @@ -0,0 +1,6 @@ +RSpec::Matchers.define :gitlab_git_repository_with do |values| + match do |actual| + actual.is_a?(Gitlab::Git::Repository) && + values.all? { |k, v| actual.send(k) == v } + end +end diff --git a/spec/support/milestone_tabs_examples.rb b/spec/support/milestone_tabs_examples.rb new file mode 100644 index 00000000000..c69f8e11008 --- /dev/null +++ b/spec/support/milestone_tabs_examples.rb @@ -0,0 +1,68 @@ +shared_examples 'milestone tabs' do + def go(path, extra_params = {}) + params = if milestone.is_a?(GlobalMilestone) + { group_id: group.id, id: milestone.safe_title, title: milestone.title } + else + { namespace_id: project.namespace.to_param, project_id: project, id: milestone.iid } + end + + get path, params.merge(extra_params) + end + + describe '#merge_requests' do + context 'as html' do + before { go(:merge_requests, format: 'html') } + + it 'redirects to milestone#show' do + expect(response).to redirect_to(milestone_path) + end + end + + context 'as json' do + before { go(:merge_requests, format: 'json') } + + it 'renders the merge requests tab template to a string' do + expect(response).to render_template('shared/milestones/_merge_requests_tab') + expect(json_response).to have_key('html') + end + end + end + + describe '#participants' do + context 'as html' do + before { go(:participants, format: 'html') } + + it 'redirects to milestone#show' do + expect(response).to redirect_to(milestone_path) + end + end + + context 'as json' do + before { go(:participants, format: 'json') } + + it 'renders the participants tab template to a string' do + expect(response).to render_template('shared/milestones/_participants_tab') + expect(json_response).to have_key('html') + end + end + end + + describe '#labels' do + context 'as html' do + before { go(:labels, format: 'html') } + + it 'redirects to milestone#show' do + expect(response).to redirect_to(milestone_path) + end + end + + context 'as json' do + before { go(:labels, format: 'json') } + + it 'renders the labels tab template to a string' do + expect(response).to render_template('shared/milestones/_labels_tab') + expect(json_response).to have_key('html') + end + end + end +end diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb index cc79b11616a..a204365431b 100644 --- a/spec/support/prometheus_helpers.rb +++ b/spec/support/prometheus_helpers.rb @@ -33,6 +33,10 @@ module PrometheusHelpers }) end + def stub_prometheus_request_with_exception(url, exception_type) + WebMock.stub_request(:get, url).to_raise(exception_type) + end + def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) stub_prometheus_request( prometheus_query_url(prometheus_memory_query(environment_slug)), diff --git a/spec/support/services/issuable_create_service_shared_examples.rb b/spec/support/services/issuable_create_service_shared_examples.rb deleted file mode 100644 index 4f0c745b7ee..00000000000 --- a/spec/support/services/issuable_create_service_shared_examples.rb +++ /dev/null @@ -1,52 +0,0 @@ -shared_examples 'issuable create service' do - context 'asssignee_id' do - let(:assignee) { create(:user) } - - before { project.team << [user, :master] } - - it 'removes assignee_id when user id is invalid' do - opts = { title: 'Title', description: 'Description', assignee_id: -1 } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - - it 'removes assignee_id when user id is 0' do - opts = { title: 'Title', description: 'Description', assignee_id: 0 } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - - it 'saves assignee when user id is valid' do - project.team << [assignee, :master] - opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to eq(assignee.id) - end - - context "when issuable feature is private" do - before do - project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, - merge_requests_access_level: ProjectFeature::PRIVATE) - end - - levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] - - levels.each do |level| - it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do - project.update(visibility_level: level) - opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - end - end - end -end diff --git a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb index 9e9cdf3e48b..1dd3663b944 100644 --- a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb +++ b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb @@ -49,23 +49,7 @@ shared_examples 'new issuable record that supports slash commands' do it 'assigns and sets milestone to issuable' do expect(issuable).to be_persisted - expect(issuable.assignee).to eq(assignee) - expect(issuable.milestone).to eq(milestone) - end - end - - context 'with assignee and milestone in params and command' do - let(:example_params) do - { - assignee: create(:user), - milestone_id: 1, - description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") - } - end - - it 'assigns and sets milestone to issuable from command' do - expect(issuable).to be_persisted - expect(issuable.assignee).to eq(assignee) + expect(issuable.assignees).to eq([assignee]) expect(issuable.milestone).to eq(milestone) end end diff --git a/spec/support/services/issuable_update_service_shared_examples.rb b/spec/support/services/issuable_update_service_shared_examples.rb index 49cea1e608c..8947f20562f 100644 --- a/spec/support/services/issuable_update_service_shared_examples.rb +++ b/spec/support/services/issuable_update_service_shared_examples.rb @@ -18,52 +18,4 @@ shared_examples 'issuable update service' do end end end - - context 'asssignee_id' do - it 'does not update assignee when assignee_id is invalid' do - open_issuable.update(assignee_id: user.id) - - update_issuable(assignee_id: -1) - - expect(open_issuable.reload.assignee).to eq(user) - end - - it 'unassigns assignee when user id is 0' do - open_issuable.update(assignee_id: user.id) - - update_issuable(assignee_id: 0) - - expect(open_issuable.assignee_id).to be_nil - end - - it 'saves assignee when user id is valid' do - update_issuable(assignee_id: user.id) - - expect(open_issuable.assignee_id).to eq(user.id) - end - - it 'does not update assignee_id when user cannot read issue' do - non_member = create(:user) - original_assignee = open_issuable.assignee - - update_issuable(assignee_id: non_member.id) - - expect(open_issuable.assignee_id).to eq(original_assignee.id) - end - - context "when issuable feature is private" do - levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] - - levels.each do |level| - it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do - assignee = create(:user) - project.update(visibility_level: level) - feature_visibility_attr = :"#{open_issuable.model_name.plural}_access_level" - project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) - - expect{ update_issuable(assignee_id: assignee) }.not_to change{ open_issuable.assignee } - end - end - end - end end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 0b3c6169c9b..8e31c26591b 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -27,6 +27,7 @@ module TestEnv 'expand-collapse-files' => '025db92', 'expand-collapse-lines' => '238e82d', 'video' => '8879059', + 'add-balsamiq-file' => 'b89b56d', 'crlf-diff' => '5938907', 'conflict-start' => '824be60', 'conflict-resolvable' => '1450cd6', diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index 01bc80f957e..84ef46ffa27 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -8,6 +8,7 @@ shared_examples 'issuable time tracker' do it 'updates the sidebar component when estimate is added' do submit_time('/estimate 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-estimate-only-pane' do expect(page).to have_content '3w 1d 1h' end @@ -16,6 +17,7 @@ shared_examples 'issuable time tracker' do it 'updates the sidebar component when spent is added' do submit_time('/spend 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-spend-only-pane' do expect(page).to have_content '3w 1d 1h' end @@ -25,6 +27,7 @@ shared_examples 'issuable time tracker' do submit_time('/estimate 3w 1d 1h') submit_time('/spend 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-comparison-pane' do expect(page).to have_content '3w 1d 1h' end @@ -34,7 +37,7 @@ shared_examples 'issuable time tracker' do submit_time('/estimate 3w 1d 1h') submit_time('/remove_estimate') - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do expect(page).to have_content 'No estimate or time spent' end end @@ -43,13 +46,13 @@ shared_examples 'issuable time tracker' do submit_time('/spend 3w 1d 1h') submit_time('/remove_time_spent') - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do expect(page).to have_content 'No estimate or time spent' end end it 'shows the help state when icon is clicked' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click expect(page).to have_content 'Track time with slash commands' expect(page).to have_content 'Learn more' @@ -57,7 +60,7 @@ shared_examples 'issuable time tracker' do end it 'hides the help state when close icon is clicked' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click find('.close-help-button').click @@ -67,7 +70,7 @@ shared_examples 'issuable time tracker' do end it 'displays the correct help url' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click expect(find_link('Learn more')[:href]).to have_content('/help/workflow/time_tracking.md') diff --git a/spec/support/wait_for_requests.rb b/spec/support/wait_for_requests.rb index 0bfa7f72ff8..73da23391ee 100644 --- a/spec/support/wait_for_requests.rb +++ b/spec/support/wait_for_requests.rb @@ -1,11 +1,15 @@ +require_relative './wait_for_ajax' + module WaitForRequests extend self + include WaitForAjax # This is inspired by http://www.salsify.com/blog/engineering/tearing-capybara-ajax-tests def wait_for_requests_complete Gitlab::Testing::RequestBlockerMiddleware.block_requests! wait_for('pending AJAX requests complete') do - Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? + Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? && + finished_all_ajax_requests? end ensure Gitlab::Testing::RequestBlockerMiddleware.allow_requests! diff --git a/spec/tasks/config_lint_spec.rb b/spec/tasks/config_lint_spec.rb index c32f9a740b7..ed6c5b09663 100644 --- a/spec/tasks/config_lint_spec.rb +++ b/spec/tasks/config_lint_spec.rb @@ -5,11 +5,11 @@ describe ConfigLint do let(:files){ ['lib/support/fake.sh'] } it 'errors out if any bash scripts have errors' do - expect { ConfigLint.run(files){ system('exit 1') } }.to raise_error(SystemExit) + expect { described_class.run(files){ system('exit 1') } }.to raise_error(SystemExit) end it 'passes if all scripts are fine' do - expect { ConfigLint.run(files){ system('exit 0') } }.not_to raise_error + expect { described_class.run(files){ system('exit 0') } }.not_to raise_error end end diff --git a/spec/tasks/gitlab/shell_rake_spec.rb b/spec/tasks/gitlab/shell_rake_spec.rb index 226d34fe2c9..ee3614c50f6 100644 --- a/spec/tasks/gitlab/shell_rake_spec.rb +++ b/spec/tasks/gitlab/shell_rake_spec.rb @@ -11,6 +11,10 @@ describe 'gitlab:shell rake tasks' do it 'invokes create_hooks task' do expect(Rake::Task['gitlab:shell:create_hooks']).to receive(:invoke) + storages = Gitlab.config.repositories.storages.values.map { |rs| rs['path'] } + expect(Kernel).to receive(:system).with('bin/install', *storages).and_call_original + expect(Kernel).to receive(:system).with('bin/compile').and_call_original + run_rake_task('gitlab:shell:install') end end diff --git a/spec/uploaders/personal_file_uploader_spec.rb b/spec/uploaders/personal_file_uploader_spec.rb new file mode 100644 index 00000000000..fb92f2ae3ab --- /dev/null +++ b/spec/uploaders/personal_file_uploader_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe PersonalFileUploader do + let(:uploader) { described_class.new(build_stubbed(:empty_project)) } + let(:snippet) { create(:personal_snippet) } + + describe '.absolute_path' do + it 'returns the correct absolute path by building it dynamically' do + upload = double(model: snippet, path: 'secret/foo.jpg') + + dynamic_segment = "personal_snippet/#{snippet.id}" + + expect(described_class.absolute_path(upload)).to end_with("#{dynamic_segment}/secret/foo.jpg") + end + end + + describe '#to_h' do + it 'returns the hass' do + uploader = described_class.new(snippet, 'secret') + + allow(uploader).to receive(:file).and_return(double(extension: 'txt', filename: 'file_name')) + expected_url = "/uploads/personal_snippet/#{snippet.id}/secret/file_name" + + expect(uploader.to_h).to eq( + alt: 'file_name', + url: expected_url, + markdown: "[file_name](#{expected_url})" + ) + end + end +end diff --git a/spec/validators/dynamic_path_validator_spec.rb b/spec/validators/dynamic_path_validator_spec.rb new file mode 100644 index 00000000000..b114bfc1bca --- /dev/null +++ b/spec/validators/dynamic_path_validator_spec.rb @@ -0,0 +1,266 @@ +require 'spec_helper' + +describe DynamicPathValidator do + let(:validator) { described_class.new(attributes: [:path]) } + + # Pass in a full path to remove the format segment: + # `/ci/lint(.:format)` -> `/ci/lint` + def without_format(path) + path.split('(', 2)[0] + end + + # Pass in a full path and get the last segment before a wildcard + # That's not a parameter + # `/*namespace_id/:project_id/builds/artifacts/*ref_name_and_path` + # -> 'builds/artifacts' + def path_before_wildcard(path) + path = path.gsub(STARTING_WITH_NAMESPACE, "") + path_segments = path.split('/').reject(&:empty?) + wildcard_index = path_segments.index { |segment| parameter?(segment) } + + segments_before_wildcard = path_segments[0..wildcard_index - 1] + + segments_before_wildcard.join('/') + end + + def parameter?(segment) + segment =~ /[*:]/ + end + + # If the path is reserved. Then no conflicting paths can# be created for any + # route using this reserved word. + # + # Both `builds/artifacts` & `build` are covered by reserving the word + # `build` + def wildcards_include?(path) + described_class::WILDCARD_ROUTES.include?(path) || + described_class::WILDCARD_ROUTES.include?(path.split('/').first) + end + + def failure_message(missing_words, constant_name, migration_helper) + missing_words = Array(missing_words) + <<-MSG + Found new routes that could cause conflicts with existing namespaced routes + for groups or projects. + + Add <#{missing_words.join(', ')}> to `DynamicPathValidator::#{constant_name} + to make sure no projects or namespaces can be created with those paths. + + To rename any existing records with those paths you can use the + `Gitlab::Database::RenameReservedpathsMigration::<VERSION>.#{migration_helper}` + migration helper. + + Make sure to make a note of the renamed records in the release blog post. + + MSG + end + + let(:all_routes) do + Rails.application.routes.routes.routes. + map { |r| r.path.spec.to_s } + end + + let(:routes_without_format) { all_routes.map { |path| without_format(path) } } + + # Routes not starting with `/:` or `/*` + # all routes not starting with a param + let(:routes_not_starting_in_wildcard) { routes_without_format.select { |p| p !~ %r{^/[:*]} } } + + let(:top_level_words) do + routes_not_starting_in_wildcard.map do |route| + route.split('/')[1] + end.compact.uniq + end + + # All routes that start with a namespaced path, that have 1 or more + # path-segments before having another wildcard parameter. + # - Starting with paths: + # - `/*namespace_id/:project_id/` + # - `/*namespace_id/:id/` + # - Followed by one or more path-parts not starting with `:` or `*` + # - Followed by a path-part that includes a wildcard parameter `*` + # At the time of writing these routes match: http://rubular.com/r/Rv2pDE5Dvw + STARTING_WITH_NAMESPACE = %r{^/\*namespace_id/:(project_)?id} + NON_PARAM_PARTS = %r{[^:*][a-z\-_/]*} + ANY_OTHER_PATH_PART = %r{[a-z\-_/:]*} + WILDCARD_SEGMENT = %r{\*} + let(:namespaced_wildcard_routes) do + routes_without_format.select do |p| + p =~ %r{#{STARTING_WITH_NAMESPACE}/#{NON_PARAM_PARTS}/#{ANY_OTHER_PATH_PART}#{WILDCARD_SEGMENT}} + end + end + + # This will return all paths that are used in a namespaced route + # before another wildcard path: + # + # /*namespace_id/:project_id/builds/artifacts/*ref_name_and_path + # /*namespace_id/:project_id/info/lfs/objects/*oid + # /*namespace_id/:project_id/commits/*id + # /*namespace_id/:project_id/builds/:build_id/artifacts/file/*path + # -> ['builds/artifacts', 'info/lfs/objects', 'commits', 'artifacts/file'] + let(:all_wildcard_paths) do + namespaced_wildcard_routes.map do |route| + path_before_wildcard(route) + end.uniq + end + + STARTING_WITH_GROUP = %r{^/groups/\*(group_)?id/} + let(:group_routes) do + routes_without_format.select do |path| + path =~ STARTING_WITH_GROUP + end + end + + let(:paths_after_group_id) do + group_routes.map do |route| + route.gsub(STARTING_WITH_GROUP, '').split('/').first + end.uniq + end + + describe 'TOP_LEVEL_ROUTES' do + it 'includes all the top level namespaces' do + failure_block = lambda do + missing_words = top_level_words - described_class::TOP_LEVEL_ROUTES + failure_message(missing_words, 'TOP_LEVEL_ROUTES', 'rename_root_paths') + end + + expect(described_class::TOP_LEVEL_ROUTES) + .to include(*top_level_words), failure_block + end + end + + describe 'GROUP_ROUTES' do + it "don't contain a second wildcard" do + failure_block = lambda do + missing_words = paths_after_group_id - described_class::GROUP_ROUTES + failure_message(missing_words, 'GROUP_ROUTES', 'rename_child_paths') + end + + expect(described_class::GROUP_ROUTES) + .to include(*paths_after_group_id), failure_block + end + end + + describe 'WILDCARD_ROUTES' do + it 'includes all paths that can be used after a namespace/project path' do + aggregate_failures do + all_wildcard_paths.each do |path| + expect(wildcards_include?(path)) + .to be(true), failure_message(path, 'WILDCARD_ROUTES', 'rename_wildcard_paths') + end + end + end + end + + describe '.without_reserved_wildcard_paths_regex' do + subject { described_class.without_reserved_wildcard_paths_regex } + + it 'rejects paths starting with a reserved top level' do + expect(subject).not_to match('dashboard/hello/world') + expect(subject).not_to match('dashboard') + end + + it 'matches valid paths with a toplevel word in a different place' do + expect(subject).to match('parent/dashboard/project-path') + end + + it 'rejects paths containing a wildcard reserved word' do + expect(subject).not_to match('hello/edit') + expect(subject).not_to match('hello/edit/in-the-middle') + expect(subject).not_to match('foo/bar1/refs/master/logs_tree') + end + + it 'matches valid paths' do + expect(subject).to match('parent/child/project-path') + end + end + + describe '.regex_excluding_child_paths' do + let(:subject) { described_class.without_reserved_child_paths_regex } + + it 'rejects paths containing a child reserved word' do + expect(subject).not_to match('hello/group_members') + expect(subject).not_to match('hello/activity/in-the-middle') + expect(subject).not_to match('foo/bar1/refs/master/logs_tree') + end + + it 'allows a child path on the top level' do + expect(subject).to match('activity/foo') + expect(subject).to match('avatar') + end + end + + describe ".valid?" do + it 'is not case sensitive' do + expect(described_class.valid?("Users")).to be_falsey + end + + it "isn't valid when the top level is reserved" do + test_path = 'u/should-be-a/reserved-word' + + expect(described_class.valid?(test_path)).to be_falsey + end + + it "isn't valid if any of the path segments is reserved" do + test_path = 'the-wildcard/wikis/is-not-allowed' + + expect(described_class.valid?(test_path)).to be_falsey + end + + it "is valid if the path doesn't contain reserved words" do + test_path = 'there-are/no-wildcards/in-this-path' + + expect(described_class.valid?(test_path)).to be_truthy + end + + it 'allows allows a child path on the last spot' do + test_path = 'there/can-be-a/project-called/labels' + + expect(described_class.valid?(test_path)).to be_truthy + end + + it 'rejects a child path somewhere else' do + test_path = 'there/can-be-no/labels/group' + + expect(described_class.valid?(test_path)).to be_falsey + end + + it 'rejects paths that are in an incorrect format' do + test_path = 'incorrect/format.git' + + expect(described_class.valid?(test_path)).to be_falsey + end + end + + describe '#path_reserved_for_record?' do + it 'reserves a sub-group named activity' do + group = build(:group, :nested, path: 'activity') + + expect(validator.path_reserved_for_record?(group, 'activity')).to be_truthy + end + + it "doesn't reserve a project called activity" do + project = build(:project, path: 'activity') + + expect(validator.path_reserved_for_record?(project, 'activity')).to be_falsey + end + end + + describe '#validates_each' do + it 'adds a message when the path is not in the correct format' do + group = build(:group) + + validator.validate_each(group, :path, "Path with spaces, and comma's!") + + expect(group.errors[:path]).to include(Gitlab::Regex.namespace_regex_message) + end + + it 'adds a message when the path is not in the correct format' do + group = build(:group, path: 'users') + + validator.validate_each(group, :path, 'users') + + expect(group.errors[:path]).to include('users is a reserved name') + end + end +end diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb new file mode 100644 index 00000000000..122075cc10e --- /dev/null +++ b/spec/views/projects/commit/show.html.haml_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe 'projects/commit/show.html.haml', :view do + let(:project) { create(:project, :repository) } + + before do + assign(:project, project) + assign(:repository, project.repository) + assign(:commit, project.commit) + assign(:noteable, project.commit) + assign(:notes, []) + assign(:diffs, project.commit.diffs) + + allow(view).to receive(:current_user).and_return(nil) + allow(view).to receive(:can?).and_return(false) + allow(view).to receive(:can_collaborate_with_project?).and_return(false) + allow(view).to receive(:current_ref).and_return(project.repository.root_ref) + allow(view).to receive(:diff_btn).and_return('') + end + + context 'inline diff view' do + before do + allow(view).to receive(:diff_view).and_return(:inline) + + render + end + + it 'keeps container-limited' do + expect(rendered).not_to have_selector('.limit-container-width') + end + end + + context 'parallel diff view' do + before do + allow(view).to receive(:diff_view).and_return(:parallel) + + render + end + + it 'spans full width' do + expect(rendered).to have_selector('.limit-container-width') + end + end +end diff --git a/spec/views/projects/tags/index.html.haml_spec.rb b/spec/views/projects/tags/index.html.haml_spec.rb new file mode 100644 index 00000000000..33122365e9a --- /dev/null +++ b/spec/views/projects/tags/index.html.haml_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe 'projects/tags/index', :view do + let(:project) { create(:project) } + + before do + assign(:project, project) + assign(:repository, project.repository) + assign(:tags, []) + + allow(view).to receive(:current_ref).and_return('master') + allow(view).to receive(:can?).and_return(false) + end + + it 'defaults sort dropdown toggle to last updated' do + render + + expect(rendered).to have_button('Last updated') + end +end diff --git a/spec/views/projects/notes/_form.html.haml_spec.rb b/spec/views/shared/notes/_form.html.haml_spec.rb index a364f9bce92..d7d0a5bf56a 100644 --- a/spec/views/projects/notes/_form.html.haml_spec.rb +++ b/spec/views/shared/notes/_form.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/notes/_form' do +describe 'shared/notes/_form' do include Devise::Test::ControllerHelpers let(:user) { create(:user) } diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb index 0765573408c..5912dd76262 100644 --- a/spec/workers/delete_user_worker_spec.rb +++ b/spec/workers/delete_user_worker_spec.rb @@ -8,13 +8,13 @@ describe DeleteUserWorker do expect_any_instance_of(Users::DestroyService).to receive(:execute). with(user, {}) - DeleteUserWorker.new.perform(current_user.id, user.id) + described_class.new.perform(current_user.id, user.id) end it "uses symbolized keys" do expect_any_instance_of(Users::DestroyService).to receive(:execute). with(user, test: "test") - DeleteUserWorker.new.perform(current_user.id, user.id, "test" => "test") + described_class.new.perform(current_user.id, user.id, "test" => "test") end end diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb index 8cf2b888f9a..a0ed85cc0b3 100644 --- a/spec/workers/emails_on_push_worker_spec.rb +++ b/spec/workers/emails_on_push_worker_spec.rb @@ -12,7 +12,7 @@ describe EmailsOnPushWorker do let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) } let(:email) { ActionMailer::Base.deliveries.last } - subject { EmailsOnPushWorker.new } + subject { described_class.new } describe "#perform" do context "when push is a new branch" do diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb index 029f35512e0..7a590f64e3c 100644 --- a/spec/workers/git_garbage_collect_worker_spec.rb +++ b/spec/workers/git_garbage_collect_worker_spec.rb @@ -6,7 +6,7 @@ describe GitGarbageCollectWorker do let(:project) { create(:project, :repository) } let(:shell) { Gitlab::Shell.new } - subject { GitGarbageCollectWorker.new } + subject { described_class.new } describe "#perform" do it "flushes ref caches when the task is 'gc'" do diff --git a/spec/workers/gitlab_usage_ping_worker_spec.rb b/spec/workers/gitlab_usage_ping_worker_spec.rb index b6c080f36f4..26241044533 100644 --- a/spec/workers/gitlab_usage_ping_worker_spec.rb +++ b/spec/workers/gitlab_usage_ping_worker_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe GitlabUsagePingWorker do - subject { GitlabUsagePingWorker.new } + subject { described_class.new } it "sends POST request" do stub_application_setting(usage_ping_enabled: true) diff --git a/spec/workers/group_destroy_worker_spec.rb b/spec/workers/group_destroy_worker_spec.rb index 1ff5a3b9034..c78efc67076 100644 --- a/spec/workers/group_destroy_worker_spec.rb +++ b/spec/workers/group_destroy_worker_spec.rb @@ -5,7 +5,7 @@ describe GroupDestroyWorker do let(:user) { create(:admin) } let!(:project) { create(:empty_project, namespace: group) } - subject { GroupDestroyWorker.new } + subject { described_class.new } describe "#perform" do it "deletes the project" do diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb index b5e1fdb8ded..303193bab9b 100644 --- a/spec/workers/merge_worker_spec.rb +++ b/spec/workers/merge_worker_spec.rb @@ -15,7 +15,7 @@ describe MergeWorker do it 'clears cache of source repo after removing source branch' do expect(source_project.repository.branch_names).to include('markdown') - MergeWorker.new.perform( + described_class.new.perform( merge_request.id, merge_request.author_id, commit_message: 'wow such merge', should_remove_source_branch: true) diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index a2a559a2369..0260416dbe2 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -5,12 +5,33 @@ describe PostReceive do let(:wrongly_encoded_changes) { changes.encode("ISO-8859-1").force_encoding("UTF-8") } let(:base64_changes) { Base64.encode64(wrongly_encoded_changes) } let(:project) { create(:project, :repository) } + let(:project_identifier) { "project-#{project.id}" } let(:key) { create(:key, user: project.owner) } let(:key_id) { key.shell_id } context "as a resque worker" do it "reponds to #perform" do - expect(PostReceive.new).to respond_to(:perform) + expect(described_class.new).to respond_to(:perform) + end + end + + context 'with a non-existing project' do + let(:project_identifier) { "project-123456789" } + let(:error_message) do + "Triggered hook for non-existing project with identifier \"#{project_identifier}\"" + end + + it "returns false and logs an error" do + expect(Gitlab::GitLogger).to receive(:error).with("POST-RECEIVE: #{error_message}") + expect(described_class.new.perform(project_identifier, key_id, base64_changes)).to be(false) + end + end + + context "with an absolute path as the project identifier" do + it "searches the project by full path" do + expect(Project).to receive(:find_by_full_path).with(project.full_path).and_call_original + + described_class.new.perform(pwd(project), key_id, base64_changes) end end @@ -25,7 +46,7 @@ describe PostReceive do it "calls GitTagPushService" do expect_any_instance_of(GitPushService).to receive(:execute).and_return(true) expect_any_instance_of(GitTagPushService).not_to receive(:execute) - PostReceive.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end end @@ -35,7 +56,7 @@ describe PostReceive do it "calls GitTagPushService" do expect_any_instance_of(GitPushService).not_to receive(:execute) expect_any_instance_of(GitTagPushService).to receive(:execute).and_return(true) - PostReceive.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end end @@ -45,12 +66,12 @@ describe PostReceive do it "does not call any of the services" do expect_any_instance_of(GitPushService).not_to receive(:execute) expect_any_instance_of(GitTagPushService).not_to receive(:execute) - PostReceive.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end end context "gitlab-ci.yml" do - subject { PostReceive.new.perform(pwd(project), key_id, base64_changes) } + subject { described_class.new.perform(project_identifier, key_id, base64_changes) } context "creates a Ci::Pipeline for every change" do before do @@ -74,8 +95,8 @@ describe PostReceive do context "webhook" do it "fetches the correct project" do - expect(Project).to receive(:find_by_full_path).with(project.path_with_namespace).and_return(project) - PostReceive.new.perform(pwd(project), key_id, base64_changes) + expect(Project).to receive(:find_by).with(id: project.id.to_s) + described_class.new.perform(project_identifier, key_id, base64_changes) end it "does not run if the author is not in the project" do @@ -85,22 +106,22 @@ describe PostReceive do expect(project).not_to receive(:execute_hooks) - expect(PostReceive.new.perform(pwd(project), key_id, base64_changes)).to be_falsey + expect(described_class.new.perform(project_identifier, key_id, base64_changes)).to be_falsey end it "asks the project to trigger all hooks" do - allow(Project).to receive(:find_by_full_path).and_return(project) + allow(Project).to receive(:find_by).and_return(project) expect(project).to receive(:execute_hooks).twice expect(project).to receive(:execute_services).twice - PostReceive.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end it "enqueues a UpdateMergeRequestsWorker job" do - allow(Project).to receive(:find_by_full_path).and_return(project) + allow(Project).to receive(:find_by).and_return(project) expect(UpdateMergeRequestsWorker).to receive(:perform_async).with(project.id, project.owner.id, any_args) - PostReceive.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end end diff --git a/spec/workers/project_destroy_worker_spec.rb b/spec/workers/project_destroy_worker_spec.rb index 0ab42f99510..3d135f40c1f 100644 --- a/spec/workers/project_destroy_worker_spec.rb +++ b/spec/workers/project_destroy_worker_spec.rb @@ -4,7 +4,7 @@ describe ProjectDestroyWorker do let(:project) { create(:project, :repository) } let(:path) { project.repository.path_to_repo } - subject { ProjectDestroyWorker.new } + subject { described_class.new } describe "#perform" do it "deletes the project" do diff --git a/spec/workers/propagate_service_template_worker_spec.rb b/spec/workers/propagate_service_template_worker_spec.rb new file mode 100644 index 00000000000..7040d5ef81c --- /dev/null +++ b/spec/workers/propagate_service_template_worker_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe PropagateServiceTemplateWorker do + let!(:service_template) do + PushoverService.create( + template: true, + active: true, + properties: { + device: 'MyDevice', + sound: 'mic', + priority: 4, + user_key: 'asdf', + api_key: '123456789' + }) + end + + before do + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain). + and_return(true) + end + + describe '#perform' do + it 'calls the propagate service with the template' do + expect(Projects::PropagateServiceTemplate).to receive(:propagate).with(service_template) + + subject.perform(service_template.id) + end + end +end diff --git a/spec/workers/remove_expired_members_worker_spec.rb b/spec/workers/remove_expired_members_worker_spec.rb index 402aa1e714e..058fdf4c009 100644 --- a/spec/workers/remove_expired_members_worker_spec.rb +++ b/spec/workers/remove_expired_members_worker_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe RemoveExpiredMembersWorker do - let(:worker) { RemoveExpiredMembersWorker.new } + let(:worker) { described_class.new } describe '#perform' do context 'project members' do diff --git a/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb b/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb index 6d42946de38..1c183ce54f4 100644 --- a/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb +++ b/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe RemoveUnreferencedLfsObjectsWorker do - let(:worker) { RemoveUnreferencedLfsObjectsWorker.new } + let(:worker) { described_class.new } describe '#perform' do let!(:unreferenced_lfs_object1) { create(:lfs_object, oid: '1') } diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index 7d6a2db2972..5e1cb74c7fc 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -5,7 +5,7 @@ describe RepositoryForkWorker do let(:fork_project) { create(:project, :repository, forked_from_project: project) } let(:shell) { Gitlab::Shell.new } - subject { RepositoryForkWorker.new } + subject { described_class.new } before do allow(subject).to receive(:gitlab_shell).and_return(shell) diff --git a/yarn.lock b/yarn.lock index fdef0665d15..8aac2b1b1cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2159,6 +2159,13 @@ expand-range@^1.8.1: dependencies: fill-range "^2.1.0" +exports-loader@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.6.4.tgz#d70fc6121975b35fc12830cf52754be2740fc886" + dependencies: + loader-utils "^1.0.2" + source-map "0.5.x" + express@^4.13.3, express@^4.14.1: version "4.14.1" resolved "https://registry.yarnpkg.com/express/-/express-4.14.1.tgz#646c237f766f148c2120aff073817b9e4d7e0d33" @@ -3102,6 +3109,10 @@ jasmine-jquery@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/jasmine-jquery/-/jasmine-jquery-2.1.1.tgz#d4095e646944a26763235769ab018d9f30f0d47b" +jed@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4" + jodid25519@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967" @@ -3179,7 +3190,7 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -4573,6 +4584,12 @@ raphael@^2.2.7: dependencies: eve-raphael "0.5.0" +raven-js@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.14.0.tgz#94dda81d975fdc4a42f193db437cf70021d654e0" + dependencies: + json-stringify-safe "^5.0.1" + raw-body@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96" @@ -5118,6 +5135,10 @@ source-map-support@^0.4.2: dependencies: source-map "^0.5.3" +source-map@0.5.x, source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + source-map@^0.1.41: version "0.1.43" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" @@ -5130,10 +5151,6 @@ source-map@^0.4.4: dependencies: amdefine ">=0.0.4" -source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - source-map@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" @@ -5184,6 +5201,10 @@ sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" +sql.js@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.4.0.tgz#23be9635520eb0ff43a741e7e830397266e88445" + sshpk@^1.7.0: version "1.10.2" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.2.tgz#d5a804ce22695515638e798dbe23273de070a5fa" |